diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index c16f817e5..c3868e55b 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -120,6 +120,7 @@ export function AgentInputArea({ const toast = useToast(); const voice = useVoiceOptional(); const voiceToggleKeys = useShortcutKeys("voice-toggle"); + const dictationCancelKeys = useShortcutKeys("dictation-cancel"); const isDictationReady = isConnected && (agentDirectoryStatus === "ready" || @@ -595,7 +596,7 @@ export function AgentInputArea({ Interrupt - + {dictationCancelKeys ? : null} diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 1651d5fcf..b8ef91dac 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -745,7 +745,7 @@ function DesktopSidebar({ Add project - + {newAgentKeys ? : null} diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 261da6465..05bfd25e4 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -33,6 +33,7 @@ import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-ur import { focusWithRetries } from "@/utils/web-focus"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Shortcut } from "@/components/ui/shortcut"; +import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; import { markScrollInvestigationEvent, @@ -210,6 +211,10 @@ export const MessageInput = forwardRef(funct markScrollInvestigationRender(investigationComponentId); const toast = useToast(); const voice = useVoiceOptional(); + const sendKeys = useShortcutKeys("message-input-send"); + const voiceMuteToggleKeys = useShortcutKeys("voice-mute-toggle"); + const dictationToggleKeys = useShortcutKeys("dictation-toggle"); + const queueKeys = useShortcutKeys("message-input-queue"); const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT); const rootRef = useRef(null); const inputWrapperRef = useRef(null); @@ -1006,10 +1011,12 @@ export const MessageInput = forwardRef(funct : "Mute voice" : "Dictation"} - + {(isRealtimeVoiceForCurrentAgent ? voiceMuteToggleKeys : dictationToggleKeys) ? ( + + ) : null} @@ -1032,7 +1039,7 @@ export const MessageInput = forwardRef(funct Queue - + {queueKeys ? : null} @@ -1055,7 +1062,7 @@ export const MessageInput = forwardRef(funct Send - + {sendKeys ? : null} diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index a1a6cb262..ae6ff3d7a 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -74,6 +74,10 @@ import { confirmDialog } from "@/utils/confirm-dialog"; import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { Shortcut } from "@/components/ui/shortcut"; +import type { ShortcutKey } from "@/utils/format-shortcut"; +import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; +import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model"; import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; @@ -116,6 +120,7 @@ interface ProjectHeaderRowProps { onPress: () => void; serverId: string | null; canCreateWorktree: boolean; + isProjectActive?: boolean; onWorkspacePress?: () => void; onWorktreeCreated?: (workspaceId: string) => void; shortcutNumber?: number | null; @@ -145,6 +150,7 @@ interface WorkspaceRowInnerProps { onArchive?: () => void; onCopyBranchName?: () => void; onCopyPath?: () => void; + archiveShortcutKeys?: ShortcutKey[] | null; } function resolveStatusDotColor(input: { @@ -267,14 +273,17 @@ function NewWorktreeButton({ visible, loading = false, testID, + showShortcutHint = false, }: { displayName: string; onPress: () => void; visible: boolean; loading?: boolean; testID: string; + showShortcutHint?: boolean; }) { const { theme } = useUnistyles(); + const newWorktreeKeys = useShortcutKeys("new-worktree"); return ( @@ -309,8 +318,13 @@ function NewWorktreeButton({ } - - New worktree + + + New worktree + {showShortcutHint && newWorktreeKeys ? ( + + ) : null} + @@ -544,6 +558,7 @@ function ProjectHeaderRow({ onPress, serverId, canCreateWorktree, + isProjectActive = false, onWorkspacePress, onWorktreeCreated, shortcutNumber = null, @@ -594,6 +609,17 @@ function ProjectHeaderRow({ toast.error(error instanceof Error ? error.message : String(error)); }, }); + useKeyboardActionHandler({ + handlerId: `worktree-new-${project.projectKey}`, + actions: ["worktree.new"], + enabled: isProjectActive && canCreateWorktree && !createWorktreeMutation.isPending, + priority: 0, + handle: () => { + createWorktreeMutation.mutate(); + return true; + }, + }); + const interaction = useLongPressDragInteraction({ drag, menuController, @@ -636,6 +662,7 @@ function ProjectHeaderRow({ onPress={() => createWorktreeMutation.mutate()} visible={isHovered || isMobileBreakpoint} loading={createWorktreeMutation.isPending} + showShortcutHint={isProjectActive} testID={`sidebar-project-new-worktree-${project.projectKey}`} /> ) : null} @@ -711,6 +738,7 @@ function WorkspaceRowInner({ onArchive, onCopyBranchName, onCopyPath, + archiveShortcutKeys, }: WorkspaceRowInnerProps) { const { theme } = useUnistyles(); const [isHovered, setIsHovered] = useState(false); @@ -792,7 +820,7 @@ function WorkspaceRowInner({ /> )} - + {onCopyPath ? ( } + trailing={archiveShortcutKeys ? : null} status={archiveStatus} pendingLabel={archivePendingLabel} onSelect={onArchive} @@ -903,7 +932,7 @@ function WorkspaceRowWithMenu({ void (async () => { const confirmed = await confirmDialog({ title: "Archive worktree?", - message: `Archive "${workspace.name}"?\n\nThis removes the worktree from the sidebar.`, + message: `Archive "${workspace.name}"?\n\nThe worktree will be removed from disk, terminals will be stopped, and agents inside will be archived.\n\nYour branch is still accessible if you committed.`, confirmLabel: "Archive", cancelLabel: "Cancel", destructive: true, @@ -991,6 +1020,23 @@ function WorkspaceRowWithMenu({ toast.copied("Branch name copied"); }, [toast, workspace.name]); + const archiveShortcutKeys = useShortcutKeys("archive-worktree"); + + useKeyboardActionHandler({ + handlerId: `worktree-archive-${workspace.workspaceKey}`, + actions: ["worktree.archive"], + enabled: selected && !isArchiving, + priority: 0, + handle: () => { + if (isWorktree) { + handleArchiveWorktree(); + } else { + handleArchiveWorkspace(); + } + return true; + }, + }); + return ( ); } @@ -1182,6 +1229,7 @@ function FlattenedProjectRow({ drag, isDragging, dragHandleProps, + isProjectActive = false, }: { project: SidebarProjectEntry; displayName: string; @@ -1196,6 +1244,7 @@ function FlattenedProjectRow({ drag: () => void; isDragging: boolean; dragHandleProps?: DraggableListDragHandleProps; + isProjectActive?: boolean; }) { if (project.projectKind === "non_git") { return ( @@ -1226,6 +1275,7 @@ function FlattenedProjectRow({ onPress={onPress} serverId={serverId} canCreateWorktree={rowModel.trailingAction === "new_worktree"} + isProjectActive={isProjectActive} onWorkspacePress={onWorkspacePress} onWorktreeCreated={onWorktreeCreated} shortcutNumber={shortcutNumber} @@ -1327,6 +1377,13 @@ function ProjectBlock({ [activeWorkspaceSelection, collapsed, project, serverId], ); + const isProjectActive = useMemo(() => { + if (!serverId || !activeWorkspaceSelection || activeWorkspaceSelection.serverId !== serverId) { + return false; + } + return project.workspaces.some((w) => w.workspaceId === activeWorkspaceSelection.workspaceId); + }, [serverId, activeWorkspaceSelection, project.workspaces]); + const renderWorkspaceRow = useCallback( ( item: SidebarWorkspaceEntry, @@ -1419,6 +1476,7 @@ function ProjectBlock({ drag={drag} isDragging={isDragging} dragHandleProps={dragHandleProps} + isProjectActive={isProjectActive} /> ) : ( <> @@ -1432,6 +1490,7 @@ function ProjectBlock({ onPress={onToggleCollapsed} serverId={serverId} canCreateWorktree={rowModel.trailingAction === "new_worktree"} + isProjectActive={isProjectActive} onWorkspacePress={onWorkspacePress} onWorktreeCreated={onWorktreeCreated} drag={drag} @@ -1923,9 +1982,18 @@ const styles = StyleSheet.create((theme) => ({ justifyContent: "center", flexShrink: 0, }, + projectActionTooltipRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, projectActionTooltipText: { color: theme.colors.foreground, - fontSize: theme.fontSize.xs, + fontSize: theme.fontSize.sm, + }, + projectActionTooltipShortcut: { + backgroundColor: theme.colors.surface3, + borderColor: theme.colors.borderAccent, }, workspaceRow: { minHeight: 36, diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index eae652b8e..d98edc09d 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -1,12 +1,8 @@ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useFocusEffect, useIsFocused } from "@react-navigation/native"; import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native"; -import { Plus, X } from "lucide-react-native"; import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated"; -import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; -import type { ListTerminalsResponse } from "@server/shared/messages"; import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; @@ -15,8 +11,6 @@ import { normalizeTerminalTransportKey, resolvePendingModifierDataInput, } from "@/utils/terminal-keys"; -import { upsertTerminalListEntry } from "@/utils/terminal-list"; -import { confirmDialog } from "@/utils/confirm-dialog"; import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session"; import { TerminalStreamController, @@ -29,14 +23,10 @@ import TerminalEmulator from "./terminal-emulator"; interface TerminalPaneProps { serverId: string; cwd: string; - selectedTerminalId: string | null; - onSelectedTerminalIdChange?: (terminalId: string | null) => void; - hideHeader?: boolean; - manageTerminalDirectorySubscription?: boolean; + terminalId: string; } const MAX_OUTPUT_CHARS = 200_000; -const TERMINAL_TAB_MAX_WIDTH = 220; const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320]; const MODIFIER_LABELS = { @@ -79,8 +69,6 @@ type PendingTerminalInput = }; }; -type ListTerminalsPayload = ListTerminalsResponse["payload"]; - const EMPTY_MODIFIERS: ModifierState = { ctrl: false, shift: false, @@ -91,30 +79,10 @@ function terminalScopeKey(input: { serverId: string; cwd: string }): string { return `${input.serverId}:${input.cwd}`; } -function TerminalCloseGradient({ color, gradientId }: { color: string; gradientId: string }) { - return ( - - - - - - - - - - - - - ); -} - export function TerminalPane({ serverId, cwd, - selectedTerminalId, - onSelectedTerminalIdChange, - hideHeader = false, - manageTerminalDirectorySubscription = true, + terminalId, }: TerminalPaneProps) { const isScreenFocused = useIsFocused(); const { theme } = useUnistyles(); @@ -129,12 +97,10 @@ export function TerminalPane({ enabled: isMobile, }); - const queryClient = useQueryClient(); const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]); - const terminalsQueryKey = useMemo(() => ["terminals", serverId, cwd] as const, [cwd, serverId]); const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null); const streamControllerRef = useRef(null); const workspaceTerminalSession = useMemo( @@ -157,20 +123,20 @@ export function TerminalPane({ getOutputSessionState, ); const selectedOutputState = useMemo(() => { - if (outputState.selectedTerminalId === selectedTerminalId) { + if (outputState.selectedTerminalId === terminalId) { return outputState; } return { ...outputState, - selectedTerminalId, - snapshotText: outputSession.readSnapshot({ terminalId: selectedTerminalId }), + selectedTerminalId: terminalId, + snapshotText: outputSession.readSnapshot({ terminalId }), snapshotSequence: 0, chunkText: "", chunkSequence: 0, chunkReplay: false, }; - }, [outputSession, outputState, selectedTerminalId]); + }, [outputSession, outputState, terminalId]); const [activeStream, setActiveStream] = useState<{ terminalId: string; streamId: number; @@ -180,68 +146,14 @@ export function TerminalPane({ const [modifiers, setModifiers] = useState(EMPTY_MODIFIERS); const [focusRequestToken, setFocusRequestToken] = useState(0); const [resizeRequestToken, setResizeRequestToken] = useState(0); - const [hoveredTerminalId, setHoveredTerminalId] = useState(null); - const [hoveredCloseTerminalId, setHoveredCloseTerminalId] = useState(null); - const hoverOutTimeoutRef = useRef | null>(null); - const selectedTerminalIdRef = useRef(selectedTerminalId); + const terminalIdRef = useRef(terminalId); const pendingTerminalInputRef = useRef([]); const keyboardRefitTimeoutsRef = useRef>>([]); const lastAutoFocusKeyRef = useRef(null); - const updateSelectedTerminalId = useCallback( - (next: string | null) => { - onSelectedTerminalIdChange?.(next); - }, - [onSelectedTerminalIdChange], - ); - useEffect(() => { - selectedTerminalIdRef.current = selectedTerminalId; - }, [selectedTerminalId]); - - const clearHoverOutTimeout = useCallback(() => { - if (!hoverOutTimeoutRef.current) { - return; - } - clearTimeout(hoverOutTimeoutRef.current); - hoverOutTimeoutRef.current = null; - }, []); - - const handleTerminalTabHoverIn = useCallback( - (terminalId: string) => { - clearHoverOutTimeout(); - setHoveredTerminalId(terminalId); - }, - [clearHoverOutTimeout], - ); - - const handleTerminalTabHoverOut = useCallback( - (terminalId: string) => { - clearHoverOutTimeout(); - hoverOutTimeoutRef.current = setTimeout(() => { - setHoveredTerminalId((current) => (current === terminalId ? null : current)); - setHoveredCloseTerminalId((current) => (current === terminalId ? null : current)); - }, 50); - }, - [clearHoverOutTimeout], - ); - - const handleTerminalCloseHoverIn = useCallback( - (terminalId: string) => { - clearHoverOutTimeout(); - setHoveredTerminalId(terminalId); - setHoveredCloseTerminalId(terminalId); - }, - [clearHoverOutTimeout], - ); - - const handleTerminalCloseHoverOut = useCallback((terminalId: string) => { - setHoveredCloseTerminalId((current) => (current === terminalId ? null : current)); - }, []); - - useEffect(() => { - return () => clearHoverOutTimeout(); - }, [clearHoverOutTimeout]); + terminalIdRef.current = terminalId; + }, [terminalId]); const requestTerminalFocus = useCallback(() => { setFocusRequestToken((current) => current + 1); @@ -251,19 +163,19 @@ export function TerminalPane({ }, []); useEffect(() => { - if (isMobile || !isScreenFocused || !selectedTerminalId) { + if (isMobile || !isScreenFocused || !terminalId) { lastAutoFocusKeyRef.current = null; return; } - const nextFocusKey = `${scopeKey}:${selectedTerminalId}`; + const nextFocusKey = `${scopeKey}:${terminalId}`; if (lastAutoFocusKeyRef.current === nextFocusKey) { return; } lastAutoFocusKeyRef.current = nextFocusKey; requestTerminalFocus(); - }, [isMobile, isScreenFocused, requestTerminalFocus, scopeKey, selectedTerminalId]); + }, [isMobile, isScreenFocused, requestTerminalFocus, scopeKey, terminalId]); const clearKeyboardRefitTimeouts = useCallback(() => { if (keyboardRefitTimeoutsRef.current.length === 0) { @@ -302,7 +214,7 @@ export function TerminalPane({ useFocusEffect( useCallback(() => { - if (!selectedTerminalId) { + if (!terminalId) { return; } // Navigation transitions can temporarily report stale dimensions. @@ -318,23 +230,9 @@ export function TerminalPane({ clearTimeout(handle); } }; - }, [requestTerminalReflow, selectedTerminalId]), + }, [requestTerminalReflow, terminalId]), ); - const terminalsQuery = useQuery({ - queryKey: terminalsQueryKey, - enabled: Boolean(client && isConnected && cwd.startsWith("/")), - queryFn: async () => { - if (!client) { - throw new Error("Host is not connected"); - } - return await client.listTerminals(cwd); - }, - staleTime: 5_000, - }); - - const terminals = terminalsQuery.data?.terminals ?? []; - useEffect(() => { if (!client || !isConnected || !isScreenFocused) { return; @@ -358,110 +256,10 @@ export function TerminalPane({ }); }, [client, isConnected]); - useEffect(() => { - if (!manageTerminalDirectorySubscription || !client || !isConnected || !cwd.startsWith("/")) { - return; - } - - const unsubscribe = client.on("terminals_changed", (message) => { - if (message.type !== "terminals_changed") { - return; - } - if (message.payload.cwd !== cwd) { - return; - } - - queryClient.setQueryData(terminalsQueryKey, (current) => ({ - cwd: message.payload.cwd, - terminals: message.payload.terminals, - requestId: current?.requestId ?? `terminals-changed-${Date.now()}`, - })); - }); - - client.subscribeTerminals({ cwd }); - - return () => { - unsubscribe(); - client.unsubscribeTerminals({ cwd }); - }; - }, [ - client, - cwd, - isConnected, - manageTerminalDirectorySubscription, - queryClient, - terminalsQueryKey, - ]); - - const createTerminalMutation = useMutation({ - mutationFn: async () => { - if (!client) { - throw new Error("Host is not connected"); - } - return await client.createTerminal(cwd); - }, - onSuccess: (payload) => { - const createdTerminal = payload.terminal; - if (createdTerminal) { - queryClient.setQueryData(terminalsQueryKey, (current) => { - const nextTerminals = upsertTerminalListEntry({ - terminals: current?.terminals ?? [], - terminal: createdTerminal, - }); - - return { - cwd: current?.cwd ?? cwd, - terminals: nextTerminals, - requestId: current?.requestId ?? `terminal-create-${createdTerminal.id}`, - }; - }); - updateSelectedTerminalId(createdTerminal.id); - requestTerminalFocus(); - } - void queryClient.invalidateQueries({ - queryKey: terminalsQueryKey, - }); - }, - }); - - const killTerminalMutation = useMutation({ - mutationFn: async (terminalId: string) => { - if (!client) { - throw new Error("Host is not connected"); - } - const payload = await client.killTerminal(terminalId); - if (!payload.success) { - throw new Error("Unable to close terminal"); - } - return payload; - }, - onSuccess: (_, terminalId) => { - setHoveredTerminalId((current) => (current === terminalId ? null : current)); - outputSession.clearTerminal({ terminalId }); - if (selectedTerminalIdRef.current === terminalId) { - updateSelectedTerminalId(null); - setModifiers({ ...EMPTY_MODIFIERS }); - } - void queryClient.invalidateQueries({ - queryKey: terminalsQueryKey, - }); - void queryClient.refetchQueries({ - queryKey: terminalsQueryKey, - type: "active", - }); - }, - }); - useEffect(() => { lastReportedSizeRef.current = null; }, [scopeKey]); - useEffect(() => { - const terminalIds = terminals.map((terminal) => terminal.id); - outputSession.prune({ terminalIds }); - streamControllerRef.current?.pruneResumeOffsets({ terminalIds }); - }, [outputSession, terminals]); - const handleStreamControllerStatus = useCallback((status: TerminalStreamControllerStatus) => { setIsAttaching(status.isAttaching); setStreamError(status.error); @@ -501,7 +299,7 @@ export function TerminalPane({ streamControllerRef.current = controller; controller.setTerminal({ - terminalId: isScreenFocused ? selectedTerminalIdRef.current : null, + terminalId: isScreenFocused ? terminalIdRef.current : null, }); return () => { @@ -521,29 +319,21 @@ export function TerminalPane({ useEffect(() => { pendingTerminalInputRef.current = []; - const nextSelectedTerminalId = isScreenFocused ? selectedTerminalId : null; + const nextTerminalId = isScreenFocused ? terminalId : null; outputSession.setSelectedTerminal({ - terminalId: nextSelectedTerminalId, + terminalId: nextTerminalId, }); streamControllerRef.current?.setTerminal({ - terminalId: nextSelectedTerminalId, + terminalId: nextTerminalId, }); - }, [isScreenFocused, outputSession, selectedTerminalId]); + }, [isScreenFocused, outputSession, terminalId]); const activeStreamId = - activeStream && activeStream.terminalId === selectedTerminalId ? activeStream.streamId : null; + activeStream && activeStream.terminalId === terminalId ? activeStream.streamId : null; const getCurrentActiveStreamId = useCallback(() => { return streamControllerRef.current?.getActiveStreamId() ?? null; }, []); - const selectedTerminal = useMemo( - () => terminals.find((terminal) => terminal.id === selectedTerminalId) ?? null, - [terminals, selectedTerminalId], - ); - const handleCreateTerminal = useCallback(() => { - createTerminalMutation.mutate(); - }, [createTerminalMutation]); - const enqueuePendingTerminalInput = useCallback((entry: PendingTerminalInput) => { const queue = pendingTerminalInputRef.current; queue.push(entry); @@ -558,13 +348,13 @@ export function TerminalPane({ return false; } - const terminalId = selectedTerminalIdRef.current; - if (!terminalId) { + const currentTerminalId = terminalIdRef.current; + if (!currentTerminalId) { return false; } if (entry.type === "data") { - client.sendTerminalInput(terminalId, { + client.sendTerminalInput(currentTerminalId, { type: "input", data: entry.data, }); @@ -575,7 +365,7 @@ export function TerminalPane({ if (encoded.length === 0) { return true; } - client.sendTerminalInput(terminalId, { + client.sendTerminalInput(currentTerminalId, { type: "input", data: encoded, }); @@ -611,29 +401,6 @@ export function TerminalPane({ flushPendingTerminalInput(); }, [activeStreamId, flushPendingTerminalInput]); - const handleCloseTerminal = useCallback( - async (terminalId: string) => { - if (killTerminalMutation.isPending && killTerminalMutation.variables === terminalId) { - return; - } - - const confirmed = await confirmDialog({ - title: "Close terminal?", - message: "Any running process in this terminal will be stopped immediately.", - confirmLabel: "Close", - cancelLabel: "Cancel", - destructive: true, - }); - - if (!confirmed) { - return; - } - - killTerminalMutation.mutate(terminalId); - }, - [killTerminalMutation], - ); - const clearPendingModifiers = useCallback(() => { setModifiers({ ...EMPTY_MODIFIERS }); }, []); @@ -646,7 +413,7 @@ export function TerminalPane({ alt: boolean; meta?: boolean; }): boolean => { - if (!client || !selectedTerminalIdRef.current) { + if (!client || !terminalIdRef.current) { enqueuePendingTerminalInput({ type: "key", input: { @@ -710,7 +477,7 @@ export function TerminalPane({ } } - if (!client || !selectedTerminalIdRef.current) { + if (!client || !terminalIdRef.current) { enqueuePendingTerminalInput({ type: "data", data, @@ -741,7 +508,7 @@ export function TerminalPane({ const handleTerminalResize = useCallback( async (input: { rows: number; cols: number }) => { const { rows, cols } = input; - if (!client || !selectedTerminalId || rows <= 0 || cols <= 0) { + if (!client || !terminalId || rows <= 0 || cols <= 0) { return; } const normalizedRows = Math.floor(rows); @@ -751,13 +518,13 @@ export function TerminalPane({ return; } lastReportedSizeRef.current = { rows: normalizedRows, cols: normalizedCols }; - client.sendTerminalInput(selectedTerminalId, { + client.sendTerminalInput(terminalId, { type: "resize", rows: normalizedRows, cols: normalizedCols, }); }, - [client, selectedTerminalId], + [client, terminalId], ); const handleTerminalKey = useCallback( @@ -816,119 +583,10 @@ export function TerminalPane({ ); } - const queryError = terminalsQuery.error instanceof Error ? terminalsQuery.error.message : null; - const isCreating = createTerminalMutation.isPending; - const createError = - createTerminalMutation.error instanceof Error ? createTerminalMutation.error.message : null; - const closeError = - killTerminalMutation.error instanceof Error ? killTerminalMutation.error.message : null; - const combinedError = streamError ?? closeError ?? createError ?? queryError; - return ( - {!hideHeader ? ( - - - {terminals.map((terminal) => { - const isActive = terminal.id === selectedTerminalId; - const isTabHovered = hoveredTerminalId === terminal.id; - const isCloseHovered = hoveredCloseTerminalId === terminal.id; - const isClosingTerminal = - killTerminalMutation.isPending && killTerminalMutation.variables === terminal.id; - const shouldShowCloseButton = isTabHovered || isCloseHovered || isClosingTerminal; - const gradientId = `terminal-close-gradient-${terminal.id.replace( - /[^a-zA-Z0-9_-]/g, - "-", - )}`; - return ( - updateSelectedTerminalId(terminal.id)} - onHoverIn={() => handleTerminalTabHoverIn(terminal.id)} - onHoverOut={() => handleTerminalTabHoverOut(terminal.id)} - style={({ pressed, hovered }) => [ - styles.terminalTab, - isActive && styles.terminalTabActive, - shouldShowCloseButton && styles.terminalTabHovered, - (pressed || hovered) && styles.terminalTabHovered, - ]} - > - - {terminal.name} - - handleTerminalCloseHoverIn(terminal.id)} - onHoverOut={() => handleTerminalCloseHoverOut(terminal.id)} - onPress={(event) => { - event.stopPropagation(); - void handleCloseTerminal(terminal.id); - }} - style={({ hovered, pressed }) => [ - styles.terminalTabCloseButton, - shouldShowCloseButton - ? styles.terminalTabCloseButtonShown - : styles.terminalTabCloseButtonHidden, - ]} - > - {({ hovered = false, pressed = false }) => { - const iconColor = - hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted; - return ( - <> - - - {isClosingTerminal ? ( - - ) : ( - - )} - - - ); - }} - - - ); - })} - - - [ - styles.headerIconButton, - (hovered || pressed) && styles.headerIconButtonHovered, - ]} - > - {isCreating ? ( - - ) : ( - - )} - - - - ) : null} - - {selectedTerminal && isScreenFocused ? ( + {isScreenFocused ? ( - ) : selectedTerminal ? ( - ) : ( - - No terminal selected - + )} {isAttaching && isScreenFocused ? ( @@ -987,10 +641,10 @@ export function TerminalPane({ ) : null} - {combinedError ? ( + {streamError ? ( - {combinedError} + {streamError} ) : null} @@ -1048,100 +702,6 @@ const styles = StyleSheet.create((theme) => ({ minHeight: 0, backgroundColor: theme.colors.surface0, }, - header: { - minHeight: 48, - borderBottomWidth: 1, - borderBottomColor: theme.colors.border, - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - gap: theme.spacing[1], - paddingHorizontal: theme.spacing[1], - paddingVertical: theme.spacing[1], - }, - tabsScroll: { - flex: 1, - minWidth: 0, - }, - tabsContent: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - paddingRight: theme.spacing[2], - }, - terminalTab: { - borderRadius: theme.borderRadius.md, - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[2], - justifyContent: "center", - maxWidth: TERMINAL_TAB_MAX_WIDTH, - minWidth: 96, - overflow: "hidden", - position: "relative", - }, - terminalTabHovered: { - backgroundColor: theme.colors.surface2, - }, - terminalTabActive: { - backgroundColor: theme.colors.surface2, - }, - terminalTabText: { - minWidth: 0, - flexShrink: 1, - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.normal, - }, - terminalTabTextActive: { - color: theme.colors.foreground, - }, - terminalTabCloseButton: { - position: "absolute", - right: 0, - top: 0, - bottom: 0, - width: 32, - borderRadius: theme.borderRadius.sm, - alignItems: "flex-end", - justifyContent: "center", - paddingRight: theme.spacing[2], - }, - terminalTabCloseButtonShown: { - opacity: 1, - }, - terminalTabCloseButtonHidden: { - opacity: 0, - }, - terminalTabCloseGradient: { - ...StyleSheet.absoluteFillObject, - borderRadius: theme.borderRadius.sm, - overflow: "hidden", - zIndex: 0, - }, - terminalTabCloseIcon: { - position: "relative", - zIndex: 1, - alignItems: "center", - justifyContent: "center", - }, - headerActions: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - }, - headerIconButton: { - width: 30, - height: 30, - borderRadius: theme.borderRadius.md, - borderWidth: 1, - borderColor: theme.colors.border, - backgroundColor: theme.colors.surface1, - alignItems: "center", - justifyContent: "center", - }, - headerIconButtonHovered: { - backgroundColor: theme.colors.surface2, - }, outputContainer: { flex: 1, minHeight: 0, diff --git a/packages/app/src/components/ui/dropdown-menu.tsx b/packages/app/src/components/ui/dropdown-menu.tsx index 04c3a9886..7ba93ac83 100644 --- a/packages/app/src/components/ui/dropdown-menu.tsx +++ b/packages/app/src/components/ui/dropdown-menu.tsx @@ -559,9 +559,9 @@ const styles = StyleSheet.create((theme) => ({ left: 0, }, content: { - backgroundColor: theme.colors.surface0, + backgroundColor: theme.colors.surface1, borderWidth: 1, - borderColor: theme.colors.border, + borderColor: theme.colors.borderAccent, borderRadius: theme.borderRadius.lg, overflow: "hidden", shadowColor: "#000", @@ -604,16 +604,16 @@ const styles = StyleSheet.create((theme) => ({ borderColor: "transparent", }, itemHovered: { - backgroundColor: theme.colors.surface1, + backgroundColor: theme.colors.surface2, }, itemPressed: { - backgroundColor: theme.colors.surface1, + backgroundColor: theme.colors.surface2, }, itemSelected: { - backgroundColor: theme.colors.surface1, + backgroundColor: theme.colors.surface2, }, itemSelectedInteractive: { - backgroundColor: theme.colors.surface1, + backgroundColor: theme.colors.surface2, }, itemSelectedAccent: { backgroundColor: theme.colors.accent, diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index 02c931d1c..fe1b5865f 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -153,6 +153,21 @@ export function useKeyboardShortcuts({ id: "workspace.tab.new", scope: "workspace", }); + case "worktree.archive": + return keyboardActionDispatcher.dispatch({ + id: "worktree.archive", + scope: "sidebar", + }); + case "worktree.new": + return keyboardActionDispatcher.dispatch({ + id: "worktree.new", + scope: "sidebar", + }); + case "workspace.terminal.new": + return keyboardActionDispatcher.dispatch({ + id: "workspace.terminal.new", + scope: "workspace", + }); case "workspace.tab.close.current": return keyboardActionDispatcher.dispatch({ id: "workspace.tab.close-current", diff --git a/packages/app/src/keyboard/actions.ts b/packages/app/src/keyboard/actions.ts index 1925f1226..4cd0ad84c 100644 --- a/packages/app/src/keyboard/actions.ts +++ b/packages/app/src/keyboard/actions.ts @@ -7,6 +7,8 @@ export type KeyboardFocusScope = export type MessageInputKeyboardActionKind = | "focus" + | "send" + | "queue" | "dictation-toggle" | "dictation-cancel" | "voice-toggle" @@ -35,6 +37,9 @@ export type KeyboardActionId = | "sidebar.toggle.right" | "command-center.toggle" | "shortcuts.dialog.toggle" + | "workspace.terminal.new" + | "worktree.new" + | "worktree.archive" | "message-input.action"; export type KeyboardShortcutPayload = diff --git a/packages/app/src/keyboard/keyboard-action-dispatcher.ts b/packages/app/src/keyboard/keyboard-action-dispatcher.ts index 6c6dd0fa9..817ac7101 100644 --- a/packages/app/src/keyboard/keyboard-action-dispatcher.ts +++ b/packages/app/src/keyboard/keyboard-action-dispatcher.ts @@ -20,7 +20,10 @@ export type KeyboardActionId = | "workspace.pane.move-tab.right" | "workspace.pane.move-tab.up" | "workspace.pane.move-tab.down" - | "workspace.pane.close"; + | "workspace.pane.close" + | "workspace.terminal.new" + | "worktree.new" + | "worktree.archive"; export type KeyboardActionDefinition = | { id: "message-input.focus"; scope: KeyboardActionScope } @@ -42,7 +45,10 @@ export type KeyboardActionDefinition = | { id: "workspace.pane.move-tab.right"; scope: KeyboardActionScope } | { id: "workspace.pane.move-tab.up"; scope: KeyboardActionScope } | { id: "workspace.pane.move-tab.down"; scope: KeyboardActionScope } - | { id: "workspace.pane.close"; scope: KeyboardActionScope }; + | { id: "workspace.pane.close"; scope: KeyboardActionScope } + | { id: "workspace.terminal.new"; scope: KeyboardActionScope } + | { id: "worktree.new"; scope: KeyboardActionScope } + | { id: "worktree.archive"; scope: KeyboardActionScope }; export type KeyboardActionHandler = { handlerId: string; diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index 8794966e7..b016f6bd2 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -127,6 +127,58 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ }, }, + // --- New worktree --- + { + id: "worktree-new-cmd-o-mac", + action: "worktree.new", + combo: "Cmd+O", + when: { mac: true, commandCenter: false }, + help: { + id: "new-worktree", + section: "global", + label: "New worktree", + keys: ["mod", "O"], + }, + }, + { + id: "worktree-new-ctrl-o-non-mac", + action: "worktree.new", + combo: "Ctrl+O", + when: { mac: false, commandCenter: false, terminal: false }, + help: { + id: "new-worktree", + section: "global", + label: "New worktree", + keys: ["mod", "O"], + }, + }, + + // --- Archive worktree --- + { + id: "worktree-archive-cmd-shift-backspace-mac", + action: "worktree.archive", + combo: "Cmd+Shift+Backspace", + when: { mac: true, commandCenter: false }, + help: { + id: "archive-worktree", + section: "global", + label: "Archive worktree", + keys: ["mod", "shift", "Backspace"], + }, + }, + { + id: "worktree-archive-ctrl-shift-backspace-non-mac", + action: "worktree.archive", + combo: "Ctrl+Shift+Backspace", + when: { mac: false, commandCenter: false, terminal: false }, + help: { + id: "archive-worktree", + section: "global", + label: "Archive worktree", + keys: ["mod", "shift", "Backspace"], + }, + }, + // --- Tab management --- { id: "workspace-tab-new-cmd-t-mac", @@ -500,6 +552,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ }, }, + // --- New terminal --- + { + id: "workspace-terminal-new-cmd-shift-t-mac", + action: "workspace.terminal.new", + combo: "Cmd+Shift+T", + when: { mac: true, commandCenter: false }, + help: { + id: "workspace-terminal-new", + section: "global", + label: "New terminal", + keys: ["mod", "shift", "T"], + }, + }, + { + id: "workspace-terminal-new-ctrl-shift-t-non-mac", + action: "workspace.terminal.new", + combo: "Ctrl+Shift+T", + when: { mac: false, commandCenter: false, terminal: false }, + help: { + id: "workspace-terminal-new", + section: "global", + label: "New terminal", + keys: ["mod", "shift", "T"], + }, + }, + // --- Command center --- { id: "command-center-toggle-cmd-k-mac", @@ -674,6 +752,52 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ keys: ["Esc"], }, }, + { + id: "message-input-send-enter", + action: "message-input.action", + combo: "Enter", + when: { focusScope: "message-input", commandCenter: false }, + payload: { type: "message-input", kind: "send" }, + preventDefault: false, + stopPropagation: false, + help: { + id: "message-input-send", + section: "agent-input", + label: "Send message", + keys: ["Enter"], + }, + }, + { + id: "message-input-queue-cmd-enter-mac", + action: "message-input.action", + combo: "Cmd+Enter", + when: { mac: true, focusScope: "message-input", commandCenter: false }, + payload: { type: "message-input", kind: "queue" }, + preventDefault: false, + stopPropagation: false, + help: { + id: "message-input-queue", + section: "agent-input", + label: "Queue message", + keys: ["mod", "Enter"], + }, + }, + { + id: "message-input-queue-ctrl-enter-non-mac", + action: "message-input.action", + combo: "Ctrl+Enter", + when: { mac: false, focusScope: "message-input", commandCenter: false }, + payload: { type: "message-input", kind: "queue" }, + preventDefault: false, + stopPropagation: false, + help: { + id: "message-input-queue", + section: "agent-input", + label: "Queue message", + keys: ["mod", "Enter"], + }, + }, + { id: "message-input-voice-mute-toggle", action: "message-input.action", diff --git a/packages/app/src/keyboard/shortcut-string.ts b/packages/app/src/keyboard/shortcut-string.ts index c84ee8588..f55bcb637 100644 --- a/packages/app/src/keyboard/shortcut-string.ts +++ b/packages/app/src/keyboard/shortcut-string.ts @@ -36,6 +36,8 @@ KEY_MAP["`"] = { code: "Backquote", key: "`" }; KEY_MAP["/"] = { code: "Slash" }; KEY_MAP["?"] = { code: "Slash", key: "?" }; KEY_MAP["Space"] = { code: "Space", key: " " }; +KEY_MAP["Enter"] = { code: "Enter", key: "Enter" }; +KEY_MAP["Backspace"] = { code: "Backspace" }; KEY_MAP["Escape"] = { code: "Escape" }; KEY_MAP["ArrowLeft"] = { code: "ArrowLeft" }; KEY_MAP["ArrowRight"] = { code: "ArrowRight" }; diff --git a/packages/app/src/panels/terminal-panel.tsx b/packages/app/src/panels/terminal-panel.tsx index 11c800f1c..6c18d8d36 100644 --- a/packages/app/src/panels/terminal-panel.tsx +++ b/packages/app/src/panels/terminal-panel.tsx @@ -49,7 +49,7 @@ function useTerminalPanelDescriptor( function TerminalPanel() { const isFocused = useIsFocused(); - const { serverId, workspaceId, target, openTab } = usePaneContext(); + const { serverId, workspaceId, target } = usePaneContext(); invariant(target.kind === "terminal", "TerminalPanel requires terminal target"); if (!isFocused) { @@ -60,15 +60,7 @@ function TerminalPanel() { { - if (!terminalId) { - return; - } - openTab({ kind: "terminal", terminalId }); - }} - hideHeader - manageTerminalDirectorySubscription={false} + terminalId={target.terminalId} /> ); } diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 9c1a60980..be910ebc7 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -20,6 +20,7 @@ import { } from "@/components/ui/context-menu"; import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout"; import { @@ -308,6 +309,10 @@ export function WorkspaceDesktopTabsRow({ tabDropPreviewIndex = null, }: WorkspaceDesktopTabsRowProps) { const { theme } = useUnistyles(); + const newAgentTabKeys = useShortcutKeys("workspace-tab-new"); + const newTerminalTabKeys = useShortcutKeys("workspace-terminal-new"); + const splitRightKeys = useShortcutKeys("workspace-pane-split-right"); + const splitDownKeys = useShortcutKeys("workspace-pane-split-down"); const [tabsContainerWidth, setTabsContainerWidth] = useState(0); const [tabsActionsWidth, setTabsActionsWidth] = useState(0); @@ -448,10 +453,12 @@ export function WorkspaceDesktopTabsRow({ > - + New agent tab - + {newAgentTabKeys ? ( + + ) : null} @@ -467,8 +474,13 @@ export function WorkspaceDesktopTabsRow({ > - - New terminal tab + + + New terminal tab + {newTerminalTabKeys ? ( + + ) : null} + @@ -483,8 +495,13 @@ export function WorkspaceDesktopTabsRow({ > - - Split pane right + + + Split pane right + {splitRightKeys ? ( + + ) : null} + @@ -499,8 +516,13 @@ export function WorkspaceDesktopTabsRow({ > - - Split pane down + + + Split pane down + {splitDownKeys ? ( + + ) : null} + diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 0e7b953c2..0f67f66fc 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1465,6 +1465,9 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) case "workspace.tab.new": handleCreateDraftTab(); return true; + case "workspace.terminal.new": + handleCreateTerminal(); + return true; case "workspace.tab.close-current": if (activeTabId) { void handleCloseTabById(activeTabId); @@ -1493,7 +1496,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) return false; } }, - [activeTabId, handleCloseTabById, handleCreateDraftTab, navigateToTabId, tabs], + [activeTabId, handleCloseTabById, handleCreateDraftTab, handleCreateTerminal, navigateToTabId, tabs], ); const handleWorkspacePaneAction = useCallback( @@ -1598,6 +1601,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) "workspace.tab.close-current", "workspace.tab.navigate-index", "workspace.tab.navigate-relative", + "workspace.terminal.new", ] as const, enabled: Boolean(normalizedServerId && normalizedWorkspaceId), priority: 100, diff --git a/packages/app/src/utils/format-shortcut.ts b/packages/app/src/utils/format-shortcut.ts index 82978687d..4caffcd23 100644 --- a/packages/app/src/utils/format-shortcut.ts +++ b/packages/app/src/utils/format-shortcut.ts @@ -2,8 +2,20 @@ export type ShortcutKey = "mod" | "shift" | "alt" | "ctrl" | "meta" | string; export type ShortcutOs = "mac" | "non-mac"; +const KEY_DISPLAY: Record = { + Backspace: "⌫", + Enter: "↩", + Esc: "⎋", + Space: "␣", + Left: "←", + Right: "→", + Up: "↑", + Down: "↓", +}; + function normalizeKey(key: string): string { if (!key) return ""; + if (KEY_DISPLAY[key]) return KEY_DISPLAY[key]; if (key.length === 1) return key.toUpperCase(); return key; }