Add keyboard shortcuts for worktree actions and dynamic shortcut display
Replace all hardcoded shortcut key rendering with useShortcutKeys() so rebound shortcuts always display correctly. Add Cmd+O (new worktree), Cmd+Shift+Backspace (archive worktree) with proper active-project scoping. Show shortcut hints in tooltips and dropdown menus. Fix tooltip alignment to center, bump dropdown menu surface level, simplify TerminalPane to single-terminal, and add special key display symbols.
This commit is contained in:
@@ -120,6 +120,7 @@ export function AgentInputArea({
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const voice = useVoiceOptional();
|
const voice = useVoiceOptional();
|
||||||
const voiceToggleKeys = useShortcutKeys("voice-toggle");
|
const voiceToggleKeys = useShortcutKeys("voice-toggle");
|
||||||
|
const dictationCancelKeys = useShortcutKeys("dictation-cancel");
|
||||||
const isDictationReady =
|
const isDictationReady =
|
||||||
isConnected &&
|
isConnected &&
|
||||||
(agentDirectoryStatus === "ready" ||
|
(agentDirectoryStatus === "ready" ||
|
||||||
@@ -595,7 +596,7 @@ export function AgentInputArea({
|
|||||||
<TooltipContent side="top" align="center" offset={8}>
|
<TooltipContent side="top" align="center" offset={8}>
|
||||||
<View style={styles.tooltipRow}>
|
<View style={styles.tooltipRow}>
|
||||||
<Text style={styles.tooltipText}>Interrupt</Text>
|
<Text style={styles.tooltipText}>Interrupt</Text>
|
||||||
<Shortcut keys={["Esc"]} style={styles.tooltipShortcut} />
|
{dictationCancelKeys ? <Shortcut keys={dictationCancelKeys} style={styles.tooltipShortcut} /> : null}
|
||||||
</View>
|
</View>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -745,7 +745,7 @@ function DesktopSidebar({
|
|||||||
<TooltipContent side="top" align="center" offset={8}>
|
<TooltipContent side="top" align="center" offset={8}>
|
||||||
<View style={styles.tooltipRow}>
|
<View style={styles.tooltipRow}>
|
||||||
<Text style={styles.tooltipText}>Add project</Text>
|
<Text style={styles.tooltipText}>Add project</Text>
|
||||||
<Shortcut keys={["⌘", "⇧", "O"]} />
|
{newAgentKeys ? <Shortcut keys={newAgentKeys} /> : null}
|
||||||
</View>
|
</View>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-ur
|
|||||||
import { focusWithRetries } from "@/utils/web-focus";
|
import { focusWithRetries } from "@/utils/web-focus";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||||
import {
|
import {
|
||||||
markScrollInvestigationEvent,
|
markScrollInvestigationEvent,
|
||||||
@@ -210,6 +211,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
markScrollInvestigationRender(investigationComponentId);
|
markScrollInvestigationRender(investigationComponentId);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const voice = useVoiceOptional();
|
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 [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
|
||||||
const rootRef = useRef<View | null>(null);
|
const rootRef = useRef<View | null>(null);
|
||||||
const inputWrapperRef = useRef<View | null>(null);
|
const inputWrapperRef = useRef<View | null>(null);
|
||||||
@@ -1006,10 +1011,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
: "Mute voice"
|
: "Mute voice"
|
||||||
: "Dictation"}
|
: "Dictation"}
|
||||||
</Text>
|
</Text>
|
||||||
<Shortcut
|
{(isRealtimeVoiceForCurrentAgent ? voiceMuteToggleKeys : dictationToggleKeys) ? (
|
||||||
keys={isRealtimeVoiceForCurrentAgent ? ["Space"] : ["mod", "D"]}
|
<Shortcut
|
||||||
style={styles.tooltipShortcut}
|
keys={(isRealtimeVoiceForCurrentAgent ? voiceMuteToggleKeys : dictationToggleKeys)!}
|
||||||
/>
|
style={styles.tooltipShortcut}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -1032,7 +1039,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
<TooltipContent side="top" align="center" offset={8}>
|
<TooltipContent side="top" align="center" offset={8}>
|
||||||
<View style={styles.tooltipRow}>
|
<View style={styles.tooltipRow}>
|
||||||
<Text style={styles.tooltipText}>Queue</Text>
|
<Text style={styles.tooltipText}>Queue</Text>
|
||||||
<Shortcut keys={["mod", "Enter"]} style={styles.tooltipShortcut} />
|
{queueKeys ? <Shortcut keys={queueKeys} style={styles.tooltipShortcut} /> : null}
|
||||||
</View>
|
</View>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -1055,7 +1062,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
<TooltipContent side="top" align="center" offset={8}>
|
<TooltipContent side="top" align="center" offset={8}>
|
||||||
<View style={styles.tooltipRow}>
|
<View style={styles.tooltipRow}>
|
||||||
<Text style={styles.tooltipText}>Send</Text>
|
<Text style={styles.tooltipText}>Send</Text>
|
||||||
<Shortcut keys={["Enter"]} style={styles.tooltipShortcut} />
|
{sendKeys ? <Shortcut keys={sendKeys} style={styles.tooltipShortcut} /> : null}
|
||||||
</View>
|
</View>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ import { confirmDialog } from "@/utils/confirm-dialog";
|
|||||||
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
||||||
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
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 { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
|
||||||
import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
||||||
@@ -116,6 +120,7 @@ interface ProjectHeaderRowProps {
|
|||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
serverId: string | null;
|
serverId: string | null;
|
||||||
canCreateWorktree: boolean;
|
canCreateWorktree: boolean;
|
||||||
|
isProjectActive?: boolean;
|
||||||
onWorkspacePress?: () => void;
|
onWorkspacePress?: () => void;
|
||||||
onWorktreeCreated?: (workspaceId: string) => void;
|
onWorktreeCreated?: (workspaceId: string) => void;
|
||||||
shortcutNumber?: number | null;
|
shortcutNumber?: number | null;
|
||||||
@@ -145,6 +150,7 @@ interface WorkspaceRowInnerProps {
|
|||||||
onArchive?: () => void;
|
onArchive?: () => void;
|
||||||
onCopyBranchName?: () => void;
|
onCopyBranchName?: () => void;
|
||||||
onCopyPath?: () => void;
|
onCopyPath?: () => void;
|
||||||
|
archiveShortcutKeys?: ShortcutKey[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveStatusDotColor(input: {
|
function resolveStatusDotColor(input: {
|
||||||
@@ -267,14 +273,17 @@ function NewWorktreeButton({
|
|||||||
visible,
|
visible,
|
||||||
loading = false,
|
loading = false,
|
||||||
testID,
|
testID,
|
||||||
|
showShortcutHint = false,
|
||||||
}: {
|
}: {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
testID: string;
|
testID: string;
|
||||||
|
showShortcutHint?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
const newWorktreeKeys = useShortcutKeys("new-worktree");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.projectTrailingControlSlot} pointerEvents={visible ? "auto" : "none"}>
|
<View style={styles.projectTrailingControlSlot} pointerEvents={visible ? "auto" : "none"}>
|
||||||
@@ -309,8 +318,13 @@ function NewWorktreeButton({
|
|||||||
}
|
}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom" align="end" offset={8}>
|
<TooltipContent side="bottom" align="center" offset={8}>
|
||||||
<Text style={styles.projectActionTooltipText}>New worktree</Text>
|
<View style={styles.projectActionTooltipRow}>
|
||||||
|
<Text style={styles.projectActionTooltipText}>New worktree</Text>
|
||||||
|
{showShortcutHint && newWorktreeKeys ? (
|
||||||
|
<Shortcut keys={newWorktreeKeys} style={styles.projectActionTooltipShortcut} />
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</View>
|
</View>
|
||||||
@@ -544,6 +558,7 @@ function ProjectHeaderRow({
|
|||||||
onPress,
|
onPress,
|
||||||
serverId,
|
serverId,
|
||||||
canCreateWorktree,
|
canCreateWorktree,
|
||||||
|
isProjectActive = false,
|
||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
onWorktreeCreated,
|
onWorktreeCreated,
|
||||||
shortcutNumber = null,
|
shortcutNumber = null,
|
||||||
@@ -594,6 +609,17 @@ function ProjectHeaderRow({
|
|||||||
toast.error(error instanceof Error ? error.message : String(error));
|
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({
|
const interaction = useLongPressDragInteraction({
|
||||||
drag,
|
drag,
|
||||||
menuController,
|
menuController,
|
||||||
@@ -636,6 +662,7 @@ function ProjectHeaderRow({
|
|||||||
onPress={() => createWorktreeMutation.mutate()}
|
onPress={() => createWorktreeMutation.mutate()}
|
||||||
visible={isHovered || isMobileBreakpoint}
|
visible={isHovered || isMobileBreakpoint}
|
||||||
loading={createWorktreeMutation.isPending}
|
loading={createWorktreeMutation.isPending}
|
||||||
|
showShortcutHint={isProjectActive}
|
||||||
testID={`sidebar-project-new-worktree-${project.projectKey}`}
|
testID={`sidebar-project-new-worktree-${project.projectKey}`}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -711,6 +738,7 @@ function WorkspaceRowInner({
|
|||||||
onArchive,
|
onArchive,
|
||||||
onCopyBranchName,
|
onCopyBranchName,
|
||||||
onCopyPath,
|
onCopyPath,
|
||||||
|
archiveShortcutKeys,
|
||||||
}: WorkspaceRowInnerProps) {
|
}: WorkspaceRowInnerProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
@@ -792,7 +820,7 @@ function WorkspaceRowInner({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" width={200}>
|
<DropdownMenuContent align="end" width={260}>
|
||||||
{onCopyPath ? (
|
{onCopyPath ? (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
testID={`sidebar-workspace-menu-copy-path-${workspace.workspaceKey}`}
|
testID={`sidebar-workspace-menu-copy-path-${workspace.workspaceKey}`}
|
||||||
@@ -814,6 +842,7 @@ function WorkspaceRowInner({
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
|
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
|
||||||
leading={<Archive size={14} color={theme.colors.foregroundMuted} />}
|
leading={<Archive size={14} color={theme.colors.foregroundMuted} />}
|
||||||
|
trailing={archiveShortcutKeys ? <Shortcut keys={archiveShortcutKeys} /> : null}
|
||||||
status={archiveStatus}
|
status={archiveStatus}
|
||||||
pendingLabel={archivePendingLabel}
|
pendingLabel={archivePendingLabel}
|
||||||
onSelect={onArchive}
|
onSelect={onArchive}
|
||||||
@@ -903,7 +932,7 @@ function WorkspaceRowWithMenu({
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
const confirmed = await confirmDialog({
|
const confirmed = await confirmDialog({
|
||||||
title: "Archive worktree?",
|
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",
|
confirmLabel: "Archive",
|
||||||
cancelLabel: "Cancel",
|
cancelLabel: "Cancel",
|
||||||
destructive: true,
|
destructive: true,
|
||||||
@@ -991,6 +1020,23 @@ function WorkspaceRowWithMenu({
|
|||||||
toast.copied("Branch name copied");
|
toast.copied("Branch name copied");
|
||||||
}, [toast, workspace.name]);
|
}, [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 (
|
return (
|
||||||
<WorkspaceRowInner
|
<WorkspaceRowInner
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
@@ -1010,6 +1056,7 @@ function WorkspaceRowWithMenu({
|
|||||||
onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
|
onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
|
||||||
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
|
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
|
||||||
onCopyPath={handleCopyPath}
|
onCopyPath={handleCopyPath}
|
||||||
|
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1182,6 +1229,7 @@ function FlattenedProjectRow({
|
|||||||
drag,
|
drag,
|
||||||
isDragging,
|
isDragging,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
|
isProjectActive = false,
|
||||||
}: {
|
}: {
|
||||||
project: SidebarProjectEntry;
|
project: SidebarProjectEntry;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -1196,6 +1244,7 @@ function FlattenedProjectRow({
|
|||||||
drag: () => void;
|
drag: () => void;
|
||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
dragHandleProps?: DraggableListDragHandleProps;
|
dragHandleProps?: DraggableListDragHandleProps;
|
||||||
|
isProjectActive?: boolean;
|
||||||
}) {
|
}) {
|
||||||
if (project.projectKind === "non_git") {
|
if (project.projectKind === "non_git") {
|
||||||
return (
|
return (
|
||||||
@@ -1226,6 +1275,7 @@ function FlattenedProjectRow({
|
|||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
canCreateWorktree={rowModel.trailingAction === "new_worktree"}
|
canCreateWorktree={rowModel.trailingAction === "new_worktree"}
|
||||||
|
isProjectActive={isProjectActive}
|
||||||
onWorkspacePress={onWorkspacePress}
|
onWorkspacePress={onWorkspacePress}
|
||||||
onWorktreeCreated={onWorktreeCreated}
|
onWorktreeCreated={onWorktreeCreated}
|
||||||
shortcutNumber={shortcutNumber}
|
shortcutNumber={shortcutNumber}
|
||||||
@@ -1327,6 +1377,13 @@ function ProjectBlock({
|
|||||||
[activeWorkspaceSelection, collapsed, project, serverId],
|
[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(
|
const renderWorkspaceRow = useCallback(
|
||||||
(
|
(
|
||||||
item: SidebarWorkspaceEntry,
|
item: SidebarWorkspaceEntry,
|
||||||
@@ -1419,6 +1476,7 @@ function ProjectBlock({
|
|||||||
drag={drag}
|
drag={drag}
|
||||||
isDragging={isDragging}
|
isDragging={isDragging}
|
||||||
dragHandleProps={dragHandleProps}
|
dragHandleProps={dragHandleProps}
|
||||||
|
isProjectActive={isProjectActive}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -1432,6 +1490,7 @@ function ProjectBlock({
|
|||||||
onPress={onToggleCollapsed}
|
onPress={onToggleCollapsed}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
canCreateWorktree={rowModel.trailingAction === "new_worktree"}
|
canCreateWorktree={rowModel.trailingAction === "new_worktree"}
|
||||||
|
isProjectActive={isProjectActive}
|
||||||
onWorkspacePress={onWorkspacePress}
|
onWorkspacePress={onWorkspacePress}
|
||||||
onWorktreeCreated={onWorktreeCreated}
|
onWorktreeCreated={onWorktreeCreated}
|
||||||
drag={drag}
|
drag={drag}
|
||||||
@@ -1923,9 +1982,18 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
|
projectActionTooltipRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
projectActionTooltipText: {
|
projectActionTooltipText: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
projectActionTooltipShortcut: {
|
||||||
|
backgroundColor: theme.colors.surface3,
|
||||||
|
borderColor: theme.colors.borderAccent,
|
||||||
},
|
},
|
||||||
workspaceRow: {
|
workspaceRow: {
|
||||||
minHeight: 36,
|
minHeight: 36,
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
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 { useFocusEffect, useIsFocused } from "@react-navigation/native";
|
||||||
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-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 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 { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import type { ListTerminalsResponse } from "@server/shared/messages";
|
|
||||||
import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input";
|
import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
@@ -15,8 +11,6 @@ import {
|
|||||||
normalizeTerminalTransportKey,
|
normalizeTerminalTransportKey,
|
||||||
resolvePendingModifierDataInput,
|
resolvePendingModifierDataInput,
|
||||||
} from "@/utils/terminal-keys";
|
} 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 { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session";
|
||||||
import {
|
import {
|
||||||
TerminalStreamController,
|
TerminalStreamController,
|
||||||
@@ -29,14 +23,10 @@ import TerminalEmulator from "./terminal-emulator";
|
|||||||
interface TerminalPaneProps {
|
interface TerminalPaneProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
selectedTerminalId: string | null;
|
terminalId: string;
|
||||||
onSelectedTerminalIdChange?: (terminalId: string | null) => void;
|
|
||||||
hideHeader?: boolean;
|
|
||||||
manageTerminalDirectorySubscription?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_OUTPUT_CHARS = 200_000;
|
const MAX_OUTPUT_CHARS = 200_000;
|
||||||
const TERMINAL_TAB_MAX_WIDTH = 220;
|
|
||||||
const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320];
|
const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320];
|
||||||
|
|
||||||
const MODIFIER_LABELS = {
|
const MODIFIER_LABELS = {
|
||||||
@@ -79,8 +69,6 @@ type PendingTerminalInput =
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
|
||||||
|
|
||||||
const EMPTY_MODIFIERS: ModifierState = {
|
const EMPTY_MODIFIERS: ModifierState = {
|
||||||
ctrl: false,
|
ctrl: false,
|
||||||
shift: false,
|
shift: false,
|
||||||
@@ -91,30 +79,10 @@ function terminalScopeKey(input: { serverId: string; cwd: string }): string {
|
|||||||
return `${input.serverId}:${input.cwd}`;
|
return `${input.serverId}:${input.cwd}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TerminalCloseGradient({ color, gradientId }: { color: string; gradientId: string }) {
|
|
||||||
return (
|
|
||||||
<View style={styles.terminalTabCloseGradient} pointerEvents="none">
|
|
||||||
<Svg width="100%" height="100%" preserveAspectRatio="none">
|
|
||||||
<Defs>
|
|
||||||
<SvgLinearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
|
|
||||||
<Stop offset="0%" stopColor={color} stopOpacity={0} />
|
|
||||||
<Stop offset="10%" stopColor={color} stopOpacity={1} />
|
|
||||||
<Stop offset="100%" stopColor={color} stopOpacity={1} />
|
|
||||||
</SvgLinearGradient>
|
|
||||||
</Defs>
|
|
||||||
<Rect x="0" y="0" width="100%" height="100%" fill={`url(#${gradientId})`} />
|
|
||||||
</Svg>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TerminalPane({
|
export function TerminalPane({
|
||||||
serverId,
|
serverId,
|
||||||
cwd,
|
cwd,
|
||||||
selectedTerminalId,
|
terminalId,
|
||||||
onSelectedTerminalIdChange,
|
|
||||||
hideHeader = false,
|
|
||||||
manageTerminalDirectorySubscription = true,
|
|
||||||
}: TerminalPaneProps) {
|
}: TerminalPaneProps) {
|
||||||
const isScreenFocused = useIsFocused();
|
const isScreenFocused = useIsFocused();
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
@@ -129,12 +97,10 @@ export function TerminalPane({
|
|||||||
enabled: isMobile,
|
enabled: isMobile,
|
||||||
});
|
});
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const client = useHostRuntimeClient(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
|
|
||||||
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
|
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 lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
||||||
const streamControllerRef = useRef<TerminalStreamController | null>(null);
|
const streamControllerRef = useRef<TerminalStreamController | null>(null);
|
||||||
const workspaceTerminalSession = useMemo(
|
const workspaceTerminalSession = useMemo(
|
||||||
@@ -157,20 +123,20 @@ export function TerminalPane({
|
|||||||
getOutputSessionState,
|
getOutputSessionState,
|
||||||
);
|
);
|
||||||
const selectedOutputState = useMemo(() => {
|
const selectedOutputState = useMemo(() => {
|
||||||
if (outputState.selectedTerminalId === selectedTerminalId) {
|
if (outputState.selectedTerminalId === terminalId) {
|
||||||
return outputState;
|
return outputState;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...outputState,
|
...outputState,
|
||||||
selectedTerminalId,
|
selectedTerminalId: terminalId,
|
||||||
snapshotText: outputSession.readSnapshot({ terminalId: selectedTerminalId }),
|
snapshotText: outputSession.readSnapshot({ terminalId }),
|
||||||
snapshotSequence: 0,
|
snapshotSequence: 0,
|
||||||
chunkText: "",
|
chunkText: "",
|
||||||
chunkSequence: 0,
|
chunkSequence: 0,
|
||||||
chunkReplay: false,
|
chunkReplay: false,
|
||||||
};
|
};
|
||||||
}, [outputSession, outputState, selectedTerminalId]);
|
}, [outputSession, outputState, terminalId]);
|
||||||
const [activeStream, setActiveStream] = useState<{
|
const [activeStream, setActiveStream] = useState<{
|
||||||
terminalId: string;
|
terminalId: string;
|
||||||
streamId: number;
|
streamId: number;
|
||||||
@@ -180,68 +146,14 @@ export function TerminalPane({
|
|||||||
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
|
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
|
||||||
const [focusRequestToken, setFocusRequestToken] = useState(0);
|
const [focusRequestToken, setFocusRequestToken] = useState(0);
|
||||||
const [resizeRequestToken, setResizeRequestToken] = useState(0);
|
const [resizeRequestToken, setResizeRequestToken] = useState(0);
|
||||||
const [hoveredTerminalId, setHoveredTerminalId] = useState<string | null>(null);
|
const terminalIdRef = useRef<string>(terminalId);
|
||||||
const [hoveredCloseTerminalId, setHoveredCloseTerminalId] = useState<string | null>(null);
|
|
||||||
const hoverOutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const selectedTerminalIdRef = useRef<string | null>(selectedTerminalId);
|
|
||||||
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
|
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
|
||||||
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
|
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
|
||||||
const lastAutoFocusKeyRef = useRef<string | null>(null);
|
const lastAutoFocusKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const updateSelectedTerminalId = useCallback(
|
|
||||||
(next: string | null) => {
|
|
||||||
onSelectedTerminalIdChange?.(next);
|
|
||||||
},
|
|
||||||
[onSelectedTerminalIdChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
selectedTerminalIdRef.current = selectedTerminalId;
|
terminalIdRef.current = terminalId;
|
||||||
}, [selectedTerminalId]);
|
}, [terminalId]);
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
const requestTerminalFocus = useCallback(() => {
|
const requestTerminalFocus = useCallback(() => {
|
||||||
setFocusRequestToken((current) => current + 1);
|
setFocusRequestToken((current) => current + 1);
|
||||||
@@ -251,19 +163,19 @@ export function TerminalPane({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isMobile || !isScreenFocused || !selectedTerminalId) {
|
if (isMobile || !isScreenFocused || !terminalId) {
|
||||||
lastAutoFocusKeyRef.current = null;
|
lastAutoFocusKeyRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextFocusKey = `${scopeKey}:${selectedTerminalId}`;
|
const nextFocusKey = `${scopeKey}:${terminalId}`;
|
||||||
if (lastAutoFocusKeyRef.current === nextFocusKey) {
|
if (lastAutoFocusKeyRef.current === nextFocusKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
lastAutoFocusKeyRef.current = nextFocusKey;
|
lastAutoFocusKeyRef.current = nextFocusKey;
|
||||||
requestTerminalFocus();
|
requestTerminalFocus();
|
||||||
}, [isMobile, isScreenFocused, requestTerminalFocus, scopeKey, selectedTerminalId]);
|
}, [isMobile, isScreenFocused, requestTerminalFocus, scopeKey, terminalId]);
|
||||||
|
|
||||||
const clearKeyboardRefitTimeouts = useCallback(() => {
|
const clearKeyboardRefitTimeouts = useCallback(() => {
|
||||||
if (keyboardRefitTimeoutsRef.current.length === 0) {
|
if (keyboardRefitTimeoutsRef.current.length === 0) {
|
||||||
@@ -302,7 +214,7 @@ export function TerminalPane({
|
|||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
if (!selectedTerminalId) {
|
if (!terminalId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Navigation transitions can temporarily report stale dimensions.
|
// Navigation transitions can temporarily report stale dimensions.
|
||||||
@@ -318,23 +230,9 @@ export function TerminalPane({
|
|||||||
clearTimeout(handle);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!client || !isConnected || !isScreenFocused) {
|
if (!client || !isConnected || !isScreenFocused) {
|
||||||
return;
|
return;
|
||||||
@@ -358,110 +256,10 @@ export function TerminalPane({
|
|||||||
});
|
});
|
||||||
}, [client, isConnected]);
|
}, [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<ListTerminalsPayload>(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<ListTerminalsPayload>(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(() => {
|
useEffect(() => {
|
||||||
lastReportedSizeRef.current = null;
|
lastReportedSizeRef.current = null;
|
||||||
}, [scopeKey]);
|
}, [scopeKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const terminalIds = terminals.map((terminal) => terminal.id);
|
|
||||||
outputSession.prune({ terminalIds });
|
|
||||||
streamControllerRef.current?.pruneResumeOffsets({ terminalIds });
|
|
||||||
}, [outputSession, terminals]);
|
|
||||||
|
|
||||||
const handleStreamControllerStatus = useCallback((status: TerminalStreamControllerStatus) => {
|
const handleStreamControllerStatus = useCallback((status: TerminalStreamControllerStatus) => {
|
||||||
setIsAttaching(status.isAttaching);
|
setIsAttaching(status.isAttaching);
|
||||||
setStreamError(status.error);
|
setStreamError(status.error);
|
||||||
@@ -501,7 +299,7 @@ export function TerminalPane({
|
|||||||
|
|
||||||
streamControllerRef.current = controller;
|
streamControllerRef.current = controller;
|
||||||
controller.setTerminal({
|
controller.setTerminal({
|
||||||
terminalId: isScreenFocused ? selectedTerminalIdRef.current : null,
|
terminalId: isScreenFocused ? terminalIdRef.current : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -521,29 +319,21 @@ export function TerminalPane({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
pendingTerminalInputRef.current = [];
|
pendingTerminalInputRef.current = [];
|
||||||
const nextSelectedTerminalId = isScreenFocused ? selectedTerminalId : null;
|
const nextTerminalId = isScreenFocused ? terminalId : null;
|
||||||
outputSession.setSelectedTerminal({
|
outputSession.setSelectedTerminal({
|
||||||
terminalId: nextSelectedTerminalId,
|
terminalId: nextTerminalId,
|
||||||
});
|
});
|
||||||
streamControllerRef.current?.setTerminal({
|
streamControllerRef.current?.setTerminal({
|
||||||
terminalId: nextSelectedTerminalId,
|
terminalId: nextTerminalId,
|
||||||
});
|
});
|
||||||
}, [isScreenFocused, outputSession, selectedTerminalId]);
|
}, [isScreenFocused, outputSession, terminalId]);
|
||||||
|
|
||||||
const activeStreamId =
|
const activeStreamId =
|
||||||
activeStream && activeStream.terminalId === selectedTerminalId ? activeStream.streamId : null;
|
activeStream && activeStream.terminalId === terminalId ? activeStream.streamId : null;
|
||||||
const getCurrentActiveStreamId = useCallback(() => {
|
const getCurrentActiveStreamId = useCallback(() => {
|
||||||
return streamControllerRef.current?.getActiveStreamId() ?? null;
|
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 enqueuePendingTerminalInput = useCallback((entry: PendingTerminalInput) => {
|
||||||
const queue = pendingTerminalInputRef.current;
|
const queue = pendingTerminalInputRef.current;
|
||||||
queue.push(entry);
|
queue.push(entry);
|
||||||
@@ -558,13 +348,13 @@ export function TerminalPane({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const terminalId = selectedTerminalIdRef.current;
|
const currentTerminalId = terminalIdRef.current;
|
||||||
if (!terminalId) {
|
if (!currentTerminalId) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.type === "data") {
|
if (entry.type === "data") {
|
||||||
client.sendTerminalInput(terminalId, {
|
client.sendTerminalInput(currentTerminalId, {
|
||||||
type: "input",
|
type: "input",
|
||||||
data: entry.data,
|
data: entry.data,
|
||||||
});
|
});
|
||||||
@@ -575,7 +365,7 @@ export function TerminalPane({
|
|||||||
if (encoded.length === 0) {
|
if (encoded.length === 0) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
client.sendTerminalInput(terminalId, {
|
client.sendTerminalInput(currentTerminalId, {
|
||||||
type: "input",
|
type: "input",
|
||||||
data: encoded,
|
data: encoded,
|
||||||
});
|
});
|
||||||
@@ -611,29 +401,6 @@ export function TerminalPane({
|
|||||||
flushPendingTerminalInput();
|
flushPendingTerminalInput();
|
||||||
}, [activeStreamId, 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(() => {
|
const clearPendingModifiers = useCallback(() => {
|
||||||
setModifiers({ ...EMPTY_MODIFIERS });
|
setModifiers({ ...EMPTY_MODIFIERS });
|
||||||
}, []);
|
}, []);
|
||||||
@@ -646,7 +413,7 @@ export function TerminalPane({
|
|||||||
alt: boolean;
|
alt: boolean;
|
||||||
meta?: boolean;
|
meta?: boolean;
|
||||||
}): boolean => {
|
}): boolean => {
|
||||||
if (!client || !selectedTerminalIdRef.current) {
|
if (!client || !terminalIdRef.current) {
|
||||||
enqueuePendingTerminalInput({
|
enqueuePendingTerminalInput({
|
||||||
type: "key",
|
type: "key",
|
||||||
input: {
|
input: {
|
||||||
@@ -710,7 +477,7 @@ export function TerminalPane({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!client || !selectedTerminalIdRef.current) {
|
if (!client || !terminalIdRef.current) {
|
||||||
enqueuePendingTerminalInput({
|
enqueuePendingTerminalInput({
|
||||||
type: "data",
|
type: "data",
|
||||||
data,
|
data,
|
||||||
@@ -741,7 +508,7 @@ export function TerminalPane({
|
|||||||
const handleTerminalResize = useCallback(
|
const handleTerminalResize = useCallback(
|
||||||
async (input: { rows: number; cols: number }) => {
|
async (input: { rows: number; cols: number }) => {
|
||||||
const { rows, cols } = input;
|
const { rows, cols } = input;
|
||||||
if (!client || !selectedTerminalId || rows <= 0 || cols <= 0) {
|
if (!client || !terminalId || rows <= 0 || cols <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const normalizedRows = Math.floor(rows);
|
const normalizedRows = Math.floor(rows);
|
||||||
@@ -751,13 +518,13 @@ export function TerminalPane({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lastReportedSizeRef.current = { rows: normalizedRows, cols: normalizedCols };
|
lastReportedSizeRef.current = { rows: normalizedRows, cols: normalizedCols };
|
||||||
client.sendTerminalInput(selectedTerminalId, {
|
client.sendTerminalInput(terminalId, {
|
||||||
type: "resize",
|
type: "resize",
|
||||||
rows: normalizedRows,
|
rows: normalizedRows,
|
||||||
cols: normalizedCols,
|
cols: normalizedCols,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[client, selectedTerminalId],
|
[client, terminalId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTerminalKey = useCallback(
|
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 (
|
return (
|
||||||
<Animated.View style={[styles.container, keyboardPaddingStyle]}>
|
<Animated.View style={[styles.container, keyboardPaddingStyle]}>
|
||||||
{!hideHeader ? (
|
|
||||||
<View style={styles.header} testID="terminals-header">
|
|
||||||
<ScrollView
|
|
||||||
horizontal
|
|
||||||
style={styles.tabsScroll}
|
|
||||||
contentContainerStyle={styles.tabsContent}
|
|
||||||
showsHorizontalScrollIndicator={false}
|
|
||||||
>
|
|
||||||
{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 (
|
|
||||||
<Pressable
|
|
||||||
key={terminal.id}
|
|
||||||
testID={`terminal-tab-${terminal.id}`}
|
|
||||||
onPress={() => 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,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={[styles.terminalTabText, isActive && styles.terminalTabTextActive]}
|
|
||||||
numberOfLines={1}
|
|
||||||
ellipsizeMode="tail"
|
|
||||||
>
|
|
||||||
{terminal.name}
|
|
||||||
</Text>
|
|
||||||
<Pressable
|
|
||||||
testID={`terminal-close-${terminal.id}`}
|
|
||||||
pointerEvents={shouldShowCloseButton ? "auto" : "none"}
|
|
||||||
disabled={!shouldShowCloseButton || isClosingTerminal}
|
|
||||||
onHoverIn={() => 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 (
|
|
||||||
<>
|
|
||||||
<TerminalCloseGradient
|
|
||||||
color={theme.colors.surface2}
|
|
||||||
gradientId={gradientId}
|
|
||||||
/>
|
|
||||||
<View style={styles.terminalTabCloseIcon}>
|
|
||||||
{isClosingTerminal ? (
|
|
||||||
<ActivityIndicator size={12} color={iconColor} />
|
|
||||||
) : (
|
|
||||||
<X size={12} color={iconColor} />
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</Pressable>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ScrollView>
|
|
||||||
<View style={styles.headerActions}>
|
|
||||||
<Pressable
|
|
||||||
testID="terminals-create-button"
|
|
||||||
onPress={handleCreateTerminal}
|
|
||||||
disabled={isCreating}
|
|
||||||
style={({ hovered, pressed }) => [
|
|
||||||
styles.headerIconButton,
|
|
||||||
(hovered || pressed) && styles.headerIconButtonHovered,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{isCreating ? (
|
|
||||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
|
||||||
) : (
|
|
||||||
<Plus size={16} color={theme.colors.foregroundMuted} />
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<View style={styles.outputContainer}>
|
<View style={styles.outputContainer}>
|
||||||
{selectedTerminal && isScreenFocused ? (
|
{isScreenFocused ? (
|
||||||
<View style={styles.terminalGestureContainer}>
|
<View style={styles.terminalGestureContainer}>
|
||||||
<TerminalEmulator
|
<TerminalEmulator
|
||||||
dom={{
|
dom={{
|
||||||
@@ -941,7 +599,7 @@ export function TerminalPane({
|
|||||||
automaticallyAdjustContentInsets: false,
|
automaticallyAdjustContentInsets: false,
|
||||||
contentInsetAdjustmentBehavior: "never",
|
contentInsetAdjustmentBehavior: "never",
|
||||||
}}
|
}}
|
||||||
streamKey={`${scopeKey}:${selectedTerminal.id}`}
|
streamKey={`${scopeKey}:${terminalId}`}
|
||||||
initialOutputText={selectedOutputState.snapshotText}
|
initialOutputText={selectedOutputState.snapshotText}
|
||||||
initialOutputChunkSequence={selectedOutputState.snapshotSequence}
|
initialOutputChunkSequence={selectedOutputState.snapshotSequence}
|
||||||
outputChunkText={selectedOutputState.chunkText}
|
outputChunkText={selectedOutputState.chunkText}
|
||||||
@@ -972,12 +630,8 @@ export function TerminalPane({
|
|||||||
resizeRequestToken={resizeRequestToken}
|
resizeRequestToken={resizeRequestToken}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : selectedTerminal ? (
|
|
||||||
<View style={styles.terminalGestureContainer} />
|
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.centerState}>
|
<View style={styles.terminalGestureContainer} />
|
||||||
<Text style={styles.stateText}>No terminal selected</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isAttaching && isScreenFocused ? (
|
{isAttaching && isScreenFocused ? (
|
||||||
@@ -987,10 +641,10 @@ export function TerminalPane({
|
|||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{combinedError ? (
|
{streamError ? (
|
||||||
<View style={styles.errorRow}>
|
<View style={styles.errorRow}>
|
||||||
<Text style={styles.statusError} numberOfLines={2}>
|
<Text style={styles.statusError} numberOfLines={2}>
|
||||||
{combinedError}
|
{streamError}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1048,100 +702,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
backgroundColor: theme.colors.surface0,
|
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: {
|
outputContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
|
|||||||
@@ -559,9 +559,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
left: 0,
|
left: 0,
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
backgroundColor: theme.colors.surface0,
|
backgroundColor: theme.colors.surface1,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.borderAccent,
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
shadowColor: "#000",
|
shadowColor: "#000",
|
||||||
@@ -604,16 +604,16 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderColor: "transparent",
|
borderColor: "transparent",
|
||||||
},
|
},
|
||||||
itemHovered: {
|
itemHovered: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
itemPressed: {
|
itemPressed: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
itemSelected: {
|
itemSelected: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
itemSelectedInteractive: {
|
itemSelectedInteractive: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
itemSelectedAccent: {
|
itemSelectedAccent: {
|
||||||
backgroundColor: theme.colors.accent,
|
backgroundColor: theme.colors.accent,
|
||||||
|
|||||||
@@ -153,6 +153,21 @@ export function useKeyboardShortcuts({
|
|||||||
id: "workspace.tab.new",
|
id: "workspace.tab.new",
|
||||||
scope: "workspace",
|
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":
|
case "workspace.tab.close.current":
|
||||||
return keyboardActionDispatcher.dispatch({
|
return keyboardActionDispatcher.dispatch({
|
||||||
id: "workspace.tab.close-current",
|
id: "workspace.tab.close-current",
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export type KeyboardFocusScope =
|
|||||||
|
|
||||||
export type MessageInputKeyboardActionKind =
|
export type MessageInputKeyboardActionKind =
|
||||||
| "focus"
|
| "focus"
|
||||||
|
| "send"
|
||||||
|
| "queue"
|
||||||
| "dictation-toggle"
|
| "dictation-toggle"
|
||||||
| "dictation-cancel"
|
| "dictation-cancel"
|
||||||
| "voice-toggle"
|
| "voice-toggle"
|
||||||
@@ -35,6 +37,9 @@ export type KeyboardActionId =
|
|||||||
| "sidebar.toggle.right"
|
| "sidebar.toggle.right"
|
||||||
| "command-center.toggle"
|
| "command-center.toggle"
|
||||||
| "shortcuts.dialog.toggle"
|
| "shortcuts.dialog.toggle"
|
||||||
|
| "workspace.terminal.new"
|
||||||
|
| "worktree.new"
|
||||||
|
| "worktree.archive"
|
||||||
| "message-input.action";
|
| "message-input.action";
|
||||||
|
|
||||||
export type KeyboardShortcutPayload =
|
export type KeyboardShortcutPayload =
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ export type KeyboardActionId =
|
|||||||
| "workspace.pane.move-tab.right"
|
| "workspace.pane.move-tab.right"
|
||||||
| "workspace.pane.move-tab.up"
|
| "workspace.pane.move-tab.up"
|
||||||
| "workspace.pane.move-tab.down"
|
| "workspace.pane.move-tab.down"
|
||||||
| "workspace.pane.close";
|
| "workspace.pane.close"
|
||||||
|
| "workspace.terminal.new"
|
||||||
|
| "worktree.new"
|
||||||
|
| "worktree.archive";
|
||||||
|
|
||||||
export type KeyboardActionDefinition =
|
export type KeyboardActionDefinition =
|
||||||
| { id: "message-input.focus"; scope: KeyboardActionScope }
|
| { 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.right"; scope: KeyboardActionScope }
|
||||||
| { id: "workspace.pane.move-tab.up"; scope: KeyboardActionScope }
|
| { id: "workspace.pane.move-tab.up"; scope: KeyboardActionScope }
|
||||||
| { id: "workspace.pane.move-tab.down"; 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 = {
|
export type KeyboardActionHandler = {
|
||||||
handlerId: string;
|
handlerId: string;
|
||||||
|
|||||||
@@ -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 ---
|
// --- Tab management ---
|
||||||
{
|
{
|
||||||
id: "workspace-tab-new-cmd-t-mac",
|
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 ---
|
// --- Command center ---
|
||||||
{
|
{
|
||||||
id: "command-center-toggle-cmd-k-mac",
|
id: "command-center-toggle-cmd-k-mac",
|
||||||
@@ -674,6 +752,52 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
|||||||
keys: ["Esc"],
|
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",
|
id: "message-input-voice-mute-toggle",
|
||||||
action: "message-input.action",
|
action: "message-input.action",
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ KEY_MAP["`"] = { code: "Backquote", key: "`" };
|
|||||||
KEY_MAP["/"] = { code: "Slash" };
|
KEY_MAP["/"] = { code: "Slash" };
|
||||||
KEY_MAP["?"] = { code: "Slash", key: "?" };
|
KEY_MAP["?"] = { code: "Slash", key: "?" };
|
||||||
KEY_MAP["Space"] = { code: "Space", 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["Escape"] = { code: "Escape" };
|
||||||
KEY_MAP["ArrowLeft"] = { code: "ArrowLeft" };
|
KEY_MAP["ArrowLeft"] = { code: "ArrowLeft" };
|
||||||
KEY_MAP["ArrowRight"] = { code: "ArrowRight" };
|
KEY_MAP["ArrowRight"] = { code: "ArrowRight" };
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ function useTerminalPanelDescriptor(
|
|||||||
|
|
||||||
function TerminalPanel() {
|
function TerminalPanel() {
|
||||||
const isFocused = useIsFocused();
|
const isFocused = useIsFocused();
|
||||||
const { serverId, workspaceId, target, openTab } = usePaneContext();
|
const { serverId, workspaceId, target } = usePaneContext();
|
||||||
invariant(target.kind === "terminal", "TerminalPanel requires terminal target");
|
invariant(target.kind === "terminal", "TerminalPanel requires terminal target");
|
||||||
|
|
||||||
if (!isFocused) {
|
if (!isFocused) {
|
||||||
@@ -60,15 +60,7 @@ function TerminalPanel() {
|
|||||||
<TerminalPane
|
<TerminalPane
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
cwd={workspaceId}
|
cwd={workspaceId}
|
||||||
selectedTerminalId={target.terminalId}
|
terminalId={target.terminalId}
|
||||||
onSelectedTerminalIdChange={(terminalId) => {
|
|
||||||
if (!terminalId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
openTab({ kind: "terminal", terminalId });
|
|
||||||
}}
|
|
||||||
hideHeader
|
|
||||||
manageTerminalDirectorySubscription={false}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from "@/components/ui/context-menu";
|
} from "@/components/ui/context-menu";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||||
import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout";
|
import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout";
|
||||||
import {
|
import {
|
||||||
@@ -308,6 +309,10 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
tabDropPreviewIndex = null,
|
tabDropPreviewIndex = null,
|
||||||
}: WorkspaceDesktopTabsRowProps) {
|
}: WorkspaceDesktopTabsRowProps) {
|
||||||
const { theme } = useUnistyles();
|
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<number>(0);
|
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
|
||||||
const [tabsActionsWidth, setTabsActionsWidth] = useState<number>(0);
|
const [tabsActionsWidth, setTabsActionsWidth] = useState<number>(0);
|
||||||
|
|
||||||
@@ -448,10 +453,12 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
>
|
>
|
||||||
<SquarePen size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
<SquarePen size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom" align="end" offset={8}>
|
<TooltipContent side="bottom" align="center" offset={8}>
|
||||||
<View style={styles.newTabTooltipRow}>
|
<View style={styles.newTabTooltipRow}>
|
||||||
<Text style={styles.newTabTooltipText}>New agent tab</Text>
|
<Text style={styles.newTabTooltipText}>New agent tab</Text>
|
||||||
<Shortcut keys={["mod", "T"]} style={styles.newTabTooltipShortcut} />
|
{newAgentTabKeys ? (
|
||||||
|
<Shortcut keys={newAgentTabKeys} style={styles.newTabTooltipShortcut} />
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -467,8 +474,13 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
>
|
>
|
||||||
<SquareTerminal size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
<SquareTerminal size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom" align="end" offset={8}>
|
<TooltipContent side="bottom" align="center" offset={8}>
|
||||||
<Text style={styles.newTabTooltipText}>New terminal tab</Text>
|
<View style={styles.newTabTooltipRow}>
|
||||||
|
<Text style={styles.newTabTooltipText}>New terminal tab</Text>
|
||||||
|
{newTerminalTabKeys ? (
|
||||||
|
<Shortcut keys={newTerminalTabKeys} style={styles.newTabTooltipShortcut} />
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
@@ -483,8 +495,13 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
>
|
>
|
||||||
<Columns2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
<Columns2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom" align="end" offset={8}>
|
<TooltipContent side="bottom" align="center" offset={8}>
|
||||||
<Text style={styles.newTabTooltipText}>Split pane right</Text>
|
<View style={styles.newTabTooltipRow}>
|
||||||
|
<Text style={styles.newTabTooltipText}>Split pane right</Text>
|
||||||
|
{splitRightKeys ? (
|
||||||
|
<Shortcut keys={splitRightKeys} style={styles.newTabTooltipShortcut} />
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
@@ -499,8 +516,13 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
>
|
>
|
||||||
<Rows2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
<Rows2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom" align="end" offset={8}>
|
<TooltipContent side="bottom" align="center" offset={8}>
|
||||||
<Text style={styles.newTabTooltipText}>Split pane down</Text>
|
<View style={styles.newTabTooltipRow}>
|
||||||
|
<Text style={styles.newTabTooltipText}>Split pane down</Text>
|
||||||
|
{splitDownKeys ? (
|
||||||
|
<Shortcut keys={splitDownKeys} style={styles.newTabTooltipShortcut} />
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1465,6 +1465,9 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
case "workspace.tab.new":
|
case "workspace.tab.new":
|
||||||
handleCreateDraftTab();
|
handleCreateDraftTab();
|
||||||
return true;
|
return true;
|
||||||
|
case "workspace.terminal.new":
|
||||||
|
handleCreateTerminal();
|
||||||
|
return true;
|
||||||
case "workspace.tab.close-current":
|
case "workspace.tab.close-current":
|
||||||
if (activeTabId) {
|
if (activeTabId) {
|
||||||
void handleCloseTabById(activeTabId);
|
void handleCloseTabById(activeTabId);
|
||||||
@@ -1493,7 +1496,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeTabId, handleCloseTabById, handleCreateDraftTab, navigateToTabId, tabs],
|
[activeTabId, handleCloseTabById, handleCreateDraftTab, handleCreateTerminal, navigateToTabId, tabs],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleWorkspacePaneAction = useCallback(
|
const handleWorkspacePaneAction = useCallback(
|
||||||
@@ -1598,6 +1601,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
"workspace.tab.close-current",
|
"workspace.tab.close-current",
|
||||||
"workspace.tab.navigate-index",
|
"workspace.tab.navigate-index",
|
||||||
"workspace.tab.navigate-relative",
|
"workspace.tab.navigate-relative",
|
||||||
|
"workspace.terminal.new",
|
||||||
] as const,
|
] as const,
|
||||||
enabled: Boolean(normalizedServerId && normalizedWorkspaceId),
|
enabled: Boolean(normalizedServerId && normalizedWorkspaceId),
|
||||||
priority: 100,
|
priority: 100,
|
||||||
|
|||||||
@@ -2,8 +2,20 @@ export type ShortcutKey = "mod" | "shift" | "alt" | "ctrl" | "meta" | string;
|
|||||||
|
|
||||||
export type ShortcutOs = "mac" | "non-mac";
|
export type ShortcutOs = "mac" | "non-mac";
|
||||||
|
|
||||||
|
const KEY_DISPLAY: Record<string, string> = {
|
||||||
|
Backspace: "⌫",
|
||||||
|
Enter: "↩",
|
||||||
|
Esc: "⎋",
|
||||||
|
Space: "␣",
|
||||||
|
Left: "←",
|
||||||
|
Right: "→",
|
||||||
|
Up: "↑",
|
||||||
|
Down: "↓",
|
||||||
|
};
|
||||||
|
|
||||||
function normalizeKey(key: string): string {
|
function normalizeKey(key: string): string {
|
||||||
if (!key) return "";
|
if (!key) return "";
|
||||||
|
if (KEY_DISPLAY[key]) return KEY_DISPLAY[key];
|
||||||
if (key.length === 1) return key.toUpperCase();
|
if (key.length === 1) return key.toUpperCase();
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user