Compare commits

...
Author SHA1 Message Date
Mohamed Boudra d705d68f2f fix(desktop): simplify expo module build to plain tsc
Replace the cross-platform wrapper script with a direct tsc call.
expo-module build is just tsc + conditional --watch; tsc alone is
one-shot and cross-platform without needing env var hacks.
2026-03-22 18:19:50 +07:00
Mohamed Boudra 43a1f46047 refactor(shortcuts): rename section to "Shortcuts" and split into groups
Replace the flat "global" section with navigation, tabs-panes, projects,
and panels groups. Rename "Keyboard Shortcuts" to "Shortcuts" in settings
nav, section titles, and dialog.
2026-03-22 18:19:38 +07:00
Mohamed Boudra 1911946ed1 fix(desktop): make expo module build cross-platform 2026-03-22 18:02:26 +07:00
Mohamed Boudra 1572c95b4d Merge branch 'feat/chord-keyboard-shortcuts' 2026-03-22 17:44:51 +07:00
Mohamed Boudra cc80d05e49 refactor(shortcuts): simplify chord display and clean up capture UI 2026-03-22 17:44:49 +07:00
Mohamed Boudra 83b681cebb fix(desktop): harden packaged launcher and desktop build 2026-03-22 17:38:10 +07:00
Mohamed Boudra 57f8c7615c Add chord keyboard shortcuts and staged capture UI 2026-03-22 16:28:09 +07:00
Mohamed Boudra d9bcf2a439 dev(server): add heap snapshot and inspect flags to dev runner 2026-03-22 16:12:36 +07:00
Mohamed Boudra 051868c74c fix(app): remove terminal padding and fetch catch-up timeline on mobile resume 2026-03-22 15:45:47 +07:00
Mohamed Boudra ffee4a472a 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.
2026-03-22 15:43:45 +07:00
41 changed files with 1318 additions and 703 deletions
+74
View File
@@ -0,0 +1,74 @@
# Product
What Paseo is, who it's for, and where it's going.
## What is Paseo
Paseo is a next-generation development environment built around agents. One interface to run, monitor, and interact with coding agents across desktop, mobile, terminal, and web.
The development workflow is shifting from manually editing files to orchestrating agents that do the editing. Paseo is built for that workflow.
## Core philosophy
Freedom and flexibility. Every design decision follows from this:
- **Multi-provider** — Use any coding agent harness. Pick the right model for each job, switch freely as the landscape shifts. No vendor-lock in.
- **Cross-device** — Desktop, mobile, web, CLI. Start work at your desk, check progress from your phone, script from the terminal.
- **Self-hosted** — The daemon runs on your machine. Your code, your keys, your environment. No inference markup, no cloud dependency.
- **Respectful** - No telemetry, no forced cloud, no forced accounts
- **Open source** — AGPL-3.0. Users can inspect, fork, and contribute.
- **BYOK** — Bring your own keys. Use your subsidized plans and first-party provider pricing. Paseo adds zero cost on top.
## How it works
### Projects and workspaces
Projects are grouped in the sidebar, detected automatically from your filesystem and tagged by git remote when available.
Each project opens as a workspace. For git projects, the default workspace is the main checkout. Users can create additional workspaces, which are isolated copies (git worktrees) where agents work without affecting main.
### Inside a workspace
A workspace is a flexible canvas:
- Launch multiple agents side by side in split panes
- Open terminals alongside agents
- Mix and match providers within the same workspace
### The daemon
Paseo is a client-server system. The daemon (Node.js) runs on your machine, manages agent processes, and streams output in real time over WebSocket. Clients connect to the daemon — locally or remotely.
This architecture means:
- The daemon can run on any machine: laptop, VM, remote server
- Multiple clients can connect simultaneously
- Agents keep running when you close the app
## Target user
Anyone who builds software:
- Care about owning their tools and their data
- Use multiple AI providers and want to switch freely
- Run agents on real tasks across real projects
- Want to work from multiple devices
## What compounds over time
- **Trust** — Showing up daily, shipping in public, being open source. Earned slowly, lost quickly.
- **Community contributions** — Code, packaging, skills, agent configs. Contributors become advocates.
- **Ecosystem** — Skills, integrations, shared configs. Community-built content that makes the platform more valuable.
## Strategic bets
1. **Models commoditize.** Value moves to the orchestration layer. The best model changes monthly — the workflow layer stays.
2. **Multi-provider wins.** No single provider stays on top. Developers want the best model for each task.
3. **The daemon as infrastructure.** Server/client architecture enables deployment anywhere.
4. **Open source outlasts funding.** Open source communities are resilient. Contributors become advocates.
## Current state (March 2026)
- Desktop (Electron), mobile (iOS/Android), web, CLI
- Providers: Claude Code (Agent SDK), Codex (app-server), OpenCode
- Daily releases
- Community contributions starting (packaging, bug fixes)
- Key UX: split panes, keybinding customization, workspace model
+36
View File
@@ -25,6 +25,7 @@
},
"devDependencies": {
"@biomejs/biome": "^2.4.8",
"concurrently": "^9.2.1",
"get-port-cli": "^3.0.0",
"knip": "^5.82.1",
"patch-package": "^8.0.1",
@@ -15184,6 +15185,31 @@
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"license": "MIT"
},
"node_modules/concurrently": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "4.1.2",
"rxjs": "7.8.2",
"shell-quote": "1.8.3",
"supports-color": "8.1.1",
"tree-kill": "1.2.2",
"yargs": "17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/connect": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz",
@@ -32547,6 +32573,16 @@
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
+2 -1
View File
@@ -14,7 +14,7 @@
],
"scripts": {
"dev": "./scripts/dev.sh",
"dev:server": "NODE_ENV=development tsx packages/server/scripts/daemon-runner.ts --dev",
"dev:server": "npm run dev --workspace=@getpaseo/server",
"dev:app": "npm run start --workspace=@getpaseo/app",
"dev:website": "npm run dev --workspace=@getpaseo/website",
"postinstall": "node scripts/postinstall-patches.mjs",
@@ -59,6 +59,7 @@
},
"devDependencies": {
"@biomejs/biome": "^2.4.8",
"concurrently": "^9.2.1",
"get-port-cli": "^3.0.0",
"knip": "^5.82.1",
"patch-package": "^8.0.1",
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
"build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --prefix ../expo-two-way-audio",
"build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/expo-two-way-audio",
"eas-build-post-install": "npm run build:workspace-deps",
"android": "npm run android:development",
"android:development": "APP_VARIANT=development expo prebuild --platform android --non-interactive && APP_VARIANT=development expo run:android --variant=debug",
@@ -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" ||
@@ -593,12 +594,14 @@ export function AgentInputArea({
)}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Interrupt</Text>
<Shortcut keys={["Esc"]} style={styles.tooltipShortcut} />
</View>
</TooltipContent>
</Tooltip>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Interrupt</Text>
{dictationCancelKeys ? (
<Shortcut chord={dictationCancelKeys} style={styles.tooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
) : null;
const rightContent = (
@@ -625,7 +628,9 @@ export function AgentInputArea({
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Voice mode</Text>
{voiceToggleKeys && <Shortcut keys={voiceToggleKeys} style={styles.tooltipShortcut} />}
{voiceToggleKeys ? (
<Shortcut chord={voiceToggleKeys} style={styles.tooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
@@ -178,7 +178,7 @@ export function CommandCenter() {
</View>
</View>
{action.shortcutKeys ? (
<Shortcut keys={action.shortcutKeys} style={styles.rowShortcut} />
<Shortcut chord={action.shortcutKeys} style={styles.rowShortcut} />
) : null}
</View>
</CommandCenterRow>
@@ -21,7 +21,7 @@ export function KeyboardShortcutsDialog() {
return (
<AdaptiveModalSheet
title="Keyboard shortcuts"
title="Shortcuts"
visible={open}
onClose={() => setOpen(false)}
testID="keyboard-shortcuts-dialog"
+2 -2
View File
@@ -565,7 +565,7 @@ function MobileSidebar({
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Add project</Text>
{newAgentKeys && <Shortcut keys={newAgentKeys} />}
{newAgentKeys ? <Shortcut chord={newAgentKeys} /> : null}
</View>
</TooltipContent>
</Tooltip>
@@ -745,7 +745,7 @@ function DesktopSidebar({
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Add project</Text>
<Shortcut keys={["⌘", "⇧", "O"]} />
{newAgentKeys ? <Shortcut chord={newAgentKeys} /> : null}
</View>
</TooltipContent>
</Tooltip>
+19 -6
View File
@@ -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<MessageInputRef, MessageInputProps>(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<View | null>(null);
const inputWrapperRef = useRef<View | null>(null);
@@ -1006,10 +1011,16 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
: "Mute voice"
: "Dictation"}
</Text>
<Shortcut
keys={isRealtimeVoiceForCurrentAgent ? ["Space"] : ["mod", "D"]}
style={styles.tooltipShortcut}
/>
{(isRealtimeVoiceForCurrentAgent ? voiceMuteToggleKeys : dictationToggleKeys) ? (
<Shortcut
chord={
(isRealtimeVoiceForCurrentAgent
? voiceMuteToggleKeys
: dictationToggleKeys)!
}
style={styles.tooltipShortcut}
/>
) : null}
</View>
</TooltipContent>
</Tooltip>
@@ -1032,7 +1043,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Queue</Text>
<Shortcut keys={["mod", "Enter"]} style={styles.tooltipShortcut} />
{queueKeys ? (
<Shortcut chord={queueKeys} style={styles.tooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
@@ -1055,7 +1068,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Send</Text>
<Shortcut keys={["Enter"]} style={styles.tooltipShortcut} />
{sendKeys ? <Shortcut chord={sendKeys} style={styles.tooltipShortcut} /> : null}
</View>
</TooltipContent>
</Tooltip>
@@ -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 (
<View style={styles.projectTrailingControlSlot} pointerEvents={visible ? "auto" : "none"}>
@@ -309,8 +318,13 @@ function NewWorktreeButton({
}
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom" align="end" offset={8}>
<Text style={styles.projectActionTooltipText}>New worktree</Text>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.projectActionTooltipRow}>
<Text style={styles.projectActionTooltipText}>New worktree</Text>
{showShortcutHint && newWorktreeKeys ? (
<Shortcut chord={newWorktreeKeys} style={styles.projectActionTooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
</View>
@@ -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({
/>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={200}>
<DropdownMenuContent align="end" width={260}>
{onCopyPath ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-path-${workspace.workspaceKey}`}
@@ -814,6 +842,7 @@ function WorkspaceRowInner({
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
leading={<Archive size={14} color={theme.colors.foregroundMuted} />}
trailing={archiveShortcutKeys ? <Shortcut chord={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 (
<WorkspaceRowInner
workspace={workspace}
@@ -1010,6 +1056,7 @@ function WorkspaceRowWithMenu({
onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
onCopyPath={handleCopyPath}
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
/>
);
}
@@ -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,
@@ -617,9 +617,9 @@ export default function TerminalEmulator({
height: "100%",
overflow: "hidden",
overscrollBehavior: "none",
paddingTop: 8,
paddingBottom: 8,
paddingLeft: 8,
paddingTop: 0,
paddingBottom: 0,
paddingLeft: 0,
paddingRight: 0,
}}
/>
+34 -474
View File
@@ -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 (
<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({
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<TerminalStreamController | null>(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<ModifierState>(EMPTY_MODIFIERS);
const [focusRequestToken, setFocusRequestToken] = useState(0);
const [resizeRequestToken, setResizeRequestToken] = useState(0);
const [hoveredTerminalId, setHoveredTerminalId] = useState<string | null>(null);
const [hoveredCloseTerminalId, setHoveredCloseTerminalId] = useState<string | null>(null);
const hoverOutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const selectedTerminalIdRef = useRef<string | null>(selectedTerminalId);
const terminalIdRef = useRef<string>(terminalId);
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
const lastAutoFocusKeyRef = useRef<string | null>(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<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(() => {
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 (
<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}>
{selectedTerminal && isScreenFocused ? (
{isScreenFocused ? (
<View style={styles.terminalGestureContainer}>
<TerminalEmulator
dom={{
@@ -941,7 +599,7 @@ export function TerminalPane({
automaticallyAdjustContentInsets: false,
contentInsetAdjustmentBehavior: "never",
}}
streamKey={`${scopeKey}:${selectedTerminal.id}`}
streamKey={`${scopeKey}:${terminalId}`}
initialOutputText={selectedOutputState.snapshotText}
initialOutputChunkSequence={selectedOutputState.snapshotSequence}
outputChunkText={selectedOutputState.chunkText}
@@ -972,12 +630,8 @@ export function TerminalPane({
resizeRequestToken={resizeRequestToken}
/>
</View>
) : selectedTerminal ? (
<View style={styles.terminalGestureContainer} />
) : (
<View style={styles.centerState}>
<Text style={styles.stateText}>No terminal selected</Text>
</View>
<View style={styles.terminalGestureContainer} />
)}
{isAttaching && isScreenFocused ? (
@@ -987,10 +641,10 @@ export function TerminalPane({
) : null}
</View>
{combinedError ? (
{streamError ? (
<View style={styles.errorRow}>
<Text style={styles.statusError} numberOfLines={2}>
{combinedError}
{streamError}
</Text>
</View>
) : 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,
@@ -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,
+34 -4
View File
@@ -6,22 +6,46 @@ import { getShortcutOs } from "@/utils/shortcut-platform";
export function Shortcut({
keys,
chord,
style,
textStyle,
}: {
keys: ShortcutKey[];
keys?: ShortcutKey[];
chord?: ShortcutKey[][];
style?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
}): ReactElement {
const displayChord = chord ?? (keys ? [keys] : []);
const shortcutOs = getShortcutOs();
const singleCombo = displayChord[0];
if (!singleCombo) {
return <View style={style} />;
}
if (displayChord.length === 1) {
return (
<View style={[styles.badge, style]}>
<Text style={[styles.text, textStyle]}>{formatShortcut(singleCombo, shortcutOs)}</Text>
</View>
);
}
return (
<View style={[styles.root, style]}>
<Text style={[styles.text, textStyle]}>{formatShortcut(keys, getShortcutOs())}</Text>
<View style={[styles.sequence, style]}>
{displayChord.map(function (combo, index) {
return (
<View key={`${combo.join("+")}-${index}`} style={styles.badge}>
<Text style={[styles.text, textStyle]}>{formatShortcut(combo, shortcutOs)}</Text>
</View>
);
})}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
root: {
badge: {
paddingHorizontal: theme.spacing[1],
paddingVertical: 2,
borderRadius: theme.borderRadius.md,
@@ -29,6 +53,12 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
},
sequence: {
flexDirection: "row",
alignItems: "center",
flexWrap: "wrap",
gap: theme.spacing[1],
},
text: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
+20 -1
View File
@@ -516,12 +516,31 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const handleAppResumed = useCallback(
(awayMs: number) => {
scheduleAuthoritativeRevalidation();
if (Platform.OS !== "web") {
const session = useSessionStore.getState().sessions[serverId];
const agentId = session?.focusedAgentId;
const cursor = agentId ? session?.agentTimelineCursor.get(agentId) : undefined;
if (agentId && cursor) {
void client
.fetchAgentTimeline(agentId, {
direction: "after",
cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
limit: 0,
projection: "canonical",
})
.catch((error) => {
console.warn("[Session] failed to fetch catch-up timeline on resume", agentId, error);
});
}
}
if (awayMs < HISTORY_STALE_AFTER_MS) {
return;
}
bumpHistorySyncGeneration(serverId);
},
[bumpHistorySyncGeneration, scheduleAuthoritativeRevalidation, serverId],
[bumpHistorySyncGeneration, client, scheduleAuthoritativeRevalidation, serverId],
);
// Client activity tracking (heartbeat, push token registration)
+6 -5
View File
@@ -13,7 +13,7 @@ import {
} from "@/utils/command-center-focus-restore";
import { buildHostSettingsRoute, parseServerIdFromPathname } from "@/utils/host-routes";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { comboStringToShortcutKeys } from "@/keyboard/shortcut-string";
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { getShortcutOs } from "@/utils/shortcut-platform";
@@ -91,7 +91,7 @@ export type CommandCenterActionItem = {
title: string;
icon?: "plus" | "settings";
route?: Href;
shortcutKeys?: ShortcutKey[];
shortcutKeys?: ShortcutKey[][];
};
export type CommandCenterItem =
@@ -107,7 +107,7 @@ export type CommandCenterItem =
function resolveActionShortcutKeys(
actionId: string | undefined,
overrides: Record<string, string>,
): ShortcutKey[] | undefined {
): ShortcutKey[][] | undefined {
if (!actionId) return undefined;
const isMac = getShortcutOs() === "mac";
const isDesktop = getIsDesktop();
@@ -115,8 +115,9 @@ function resolveActionShortcutKeys(
const bindingId = getBindingIdForAction(actionId, platform);
if (!bindingId) return undefined;
const override = overrides[bindingId];
if (override) return comboStringToShortcutKeys(override);
return getDefaultKeysForAction(actionId, platform) ?? undefined;
if (override) return chordStringToShortcutKeys(override);
const defaultKeys = getDefaultKeysForAction(actionId, platform);
return defaultKeys ? [defaultKeys] : undefined;
}
export function useCommandCenter() {
@@ -1,4 +1,4 @@
import { useEffect, useMemo } from "react";
import { useEffect, useMemo, useRef } from "react";
import { Platform } from "react-native";
import { usePathname } from "expo-router";
import { getIsDesktop } from "@/constants/layout";
@@ -17,7 +17,11 @@ import {
} from "@/keyboard/actions";
import { canToggleFileExplorerShortcut } from "@/keyboard/keyboard-shortcut-routing";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { resolveKeyboardShortcut, buildEffectiveBindings } from "@/keyboard/keyboard-shortcuts";
import {
type ChordState,
resolveKeyboardShortcut,
buildEffectiveBindings,
} from "@/keyboard/keyboard-shortcuts";
import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
@@ -41,6 +45,11 @@ export function useKeyboardShortcuts({
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
const { overrides } = useKeyboardShortcutOverrides();
const bindings = useMemo(() => buildEffectiveBindings(overrides), [overrides]);
const chordStateRef = useRef<ChordState>({
candidateIndices: [],
step: 0,
timeoutId: null,
});
const activeServerIdFromPath = parseServerIdFromPathname(pathname);
const activeServerId =
hosts.find((host) => host.serverId === activeServerIdFromPath)?.serverId ??
@@ -153,6 +162,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",
@@ -256,6 +280,11 @@ export function useKeyboardShortcuts({
return;
}
const store = useKeyboardShortcutsStore.getState();
if (store.capturingShortcut) {
return;
}
const key = event.key ?? "";
if (key === "Alt" && !event.shiftKey) {
useKeyboardShortcutsStore.getState().setAltDown(true);
@@ -270,12 +299,11 @@ export function useKeyboardShortcuts({
}
}
const store = useKeyboardShortcutsStore.getState();
const focusScope = resolveKeyboardFocusScope({
target: event.target,
commandCenterOpen: store.commandCenterOpen,
});
const match = resolveKeyboardShortcut({
const result = resolveKeyboardShortcut({
event,
context: {
isMac,
@@ -288,25 +316,41 @@ export function useKeyboardShortcuts({
toggleFileExplorer,
}),
},
chordState: chordStateRef.current,
onChordReset: () => {
chordStateRef.current = {
candidateIndices: [],
step: 0,
timeoutId: null,
};
},
bindings,
});
if (!match) {
chordStateRef.current = result.nextChordState;
if (result.preventDefault) {
event.preventDefault();
event.stopPropagation();
}
if (!result.match) {
return;
}
const handled = handleAction({
action: match.action,
payload: match.payload,
action: result.match.action,
payload: result.match.payload,
event,
});
if (!handled) {
return;
}
if (match.preventDefault) {
if (result.match.preventDefault) {
event.preventDefault();
}
if (match.stopPropagation) {
if (result.match.stopPropagation) {
event.stopPropagation();
}
};
@@ -330,6 +374,14 @@ export function useKeyboardShortcuts({
window.addEventListener("blur", handleBlurOrHide);
document.addEventListener("visibilitychange", handleBlurOrHide);
return () => {
if (chordStateRef.current.timeoutId !== null) {
clearTimeout(chordStateRef.current.timeoutId);
chordStateRef.current = {
candidateIndices: [],
step: 0,
timeoutId: null,
};
}
window.removeEventListener("keydown", handleKeyDown, true);
window.removeEventListener("keyup", handleKeyUp, true);
window.removeEventListener("blur", handleBlurOrHide);
+5 -4
View File
@@ -1,12 +1,12 @@
import { useMemo } from "react";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { comboStringToShortcutKeys } from "@/keyboard/shortcut-string";
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { getIsDesktop } from "@/constants/layout";
export function useShortcutKeys(actionId: string): ShortcutKey[] | null {
export function useShortcutKeys(actionId: string): ShortcutKey[][] | null {
const { overrides } = useKeyboardShortcutOverrides();
const isMac = getShortcutOs() === "mac";
const isDesktop = getIsDesktop();
@@ -18,9 +18,10 @@ export function useShortcutKeys(actionId: string): ShortcutKey[] | null {
const override = overrides[bindingId];
if (override) {
return comboStringToShortcutKeys(override);
return chordStringToShortcutKeys(override);
}
return getDefaultKeysForAction(actionId, platform);
const defaultKeys = getDefaultKeysForAction(actionId, platform);
return defaultKeys ? [defaultKeys] : null;
}, [actionId, overrides, isMac, isDesktop]);
}
+5
View File
@@ -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 =
@@ -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;
@@ -1,8 +1,11 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
buildKeyboardShortcutHelpSections,
buildEffectiveBindings,
resolveKeyboardShortcut,
type ChordState,
type KeyboardShortcutContext,
type ParsedShortcutBinding,
} from "./keyboard-shortcuts";
function keyboardEvent(overrides: Partial<KeyboardEvent>): KeyboardEvent {
@@ -31,6 +34,30 @@ function shortcutContext(
};
}
function initialChordState(): ChordState {
return {
candidateIndices: [],
step: 0,
timeoutId: null,
};
}
function resolveShortcut(input: {
event: Partial<KeyboardEvent>;
context?: Partial<KeyboardShortcutContext>;
chordState?: ChordState;
onChordReset?: () => void;
bindings?: readonly ParsedShortcutBinding[];
}) {
return resolveKeyboardShortcut({
event: keyboardEvent(input.event),
context: shortcutContext(input.context),
chordState: input.chordState ?? initialChordState(),
onChordReset: input.onChordReset ?? (() => undefined),
...(input.bindings ? { bindings: input.bindings } : {}),
});
}
function expectShortcutResolution(input: {
event: Partial<KeyboardEvent>;
context?: Partial<KeyboardShortcutContext>;
@@ -39,29 +66,33 @@ function expectShortcutResolution(input: {
preventDefault?: boolean;
stopPropagation?: boolean;
}) {
const match = resolveKeyboardShortcut({
event: keyboardEvent(input.event),
context: shortcutContext(input.context),
const result = resolveShortcut({
event: input.event,
context: input.context,
});
expect(match?.action).toBe(input.action);
expect(result.match?.action).toBe(input.action);
if ("payload" in input) {
expect(match?.payload).toEqual(input.payload);
expect(result.match?.payload).toEqual(input.payload);
}
expect(match?.preventDefault).toBe(input.preventDefault ?? true);
expect(match?.stopPropagation).toBe(input.stopPropagation ?? true);
expect(result.match?.preventDefault).toBe(input.preventDefault ?? true);
expect(result.match?.stopPropagation).toBe(input.stopPropagation ?? true);
expect(result.preventDefault).toBe(false);
expect(result.nextChordState).toEqual(initialChordState());
}
function expectNoShortcutResolution(input: {
event: Partial<KeyboardEvent>;
context?: Partial<KeyboardShortcutContext>;
}) {
const match = resolveKeyboardShortcut({
event: keyboardEvent(input.event),
context: shortcutContext(input.context),
const result = resolveShortcut({
event: input.event,
context: input.context,
});
expect(match).toBeNull();
expect(result.match).toBeNull();
expect(result.preventDefault).toBe(false);
expect(result.nextChordState).toEqual(initialChordState());
}
type MatchingShortcutCase = {
@@ -338,6 +369,66 @@ describe("keyboard-shortcuts", () => {
it.each(nonMatchingCases)("$name", ({ event, context }) => {
expectNoShortcutResolution({ event, context });
});
it("prefers advancing chord candidates over single-combo matches on the same prefix", () => {
const bindings = buildEffectiveBindings({
"workspace-terminal-new-ctrl-shift-t-non-mac": "Ctrl+W S",
});
const chordBindingIndex = bindings.findIndex(
(binding) => binding.id === "workspace-terminal-new-ctrl-shift-t-non-mac",
);
expect(chordBindingIndex).toBeGreaterThan(-1);
const firstResult = resolveShortcut({
event: { key: "w", code: "KeyW", ctrlKey: true },
context: { isMac: false, isDesktop: true },
bindings,
});
expect(firstResult.match).toBeNull();
expect(firstResult.preventDefault).toBe(true);
expect(firstResult.nextChordState.step).toBe(1);
expect(firstResult.nextChordState.candidateIndices).toEqual([chordBindingIndex]);
const secondResult = resolveShortcut({
event: { key: "s", code: "KeyS" },
context: { isMac: false, isDesktop: true },
chordState: firstResult.nextChordState,
bindings,
});
expect(secondResult.match?.action).toBe("workspace.terminal.new");
expect(secondResult.match?.payload).toBeNull();
expect(secondResult.match?.preventDefault).toBe(true);
expect(secondResult.match?.stopPropagation).toBe(true);
expect(secondResult.preventDefault).toBe(false);
expect(secondResult.nextChordState).toEqual(initialChordState());
});
it("schedules a chord reset timeout for advancing candidates", () => {
vi.useFakeTimers();
const bindings = buildEffectiveBindings({
"workspace-terminal-new-ctrl-shift-t-non-mac": "Ctrl+W S",
});
const onChordReset = vi.fn();
const result = resolveShortcut({
event: { key: "w", code: "KeyW", ctrlKey: true },
context: { isMac: false, isDesktop: true },
onChordReset,
bindings,
});
expect(result.match).toBeNull();
expect(result.preventDefault).toBe(true);
expect(result.nextChordState.timeoutId).not.toBeNull();
vi.advanceTimersByTime(1500);
expect(onChordReset).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
});
describe("keyboard-shortcut help sections", () => {
+338 -65
View File
@@ -5,7 +5,7 @@ import type {
KeyboardShortcutPayload,
MessageInputKeyboardActionKind,
} from "@/keyboard/actions";
import { type KeyCombo, parseShortcutString } from "@/keyboard/shortcut-string";
import { type KeyCombo, parseChordString } from "@/keyboard/shortcut-string";
export type { KeyCombo } from "@/keyboard/shortcut-string";
@@ -33,8 +33,15 @@ export type KeyboardShortcutHelpRow = {
note?: string;
};
export type ShortcutSectionId =
| "navigation"
| "tabs-panes"
| "projects"
| "panels"
| "agent-input";
export type KeyboardShortcutHelpSection = {
id: "global" | "agent-input";
id: ShortcutSectionId;
title: string;
rows: KeyboardShortcutHelpRow[];
};
@@ -69,7 +76,7 @@ type ShortcutPayloadDef =
interface ShortcutHelp {
id: string;
section: "global" | "agent-input";
section: ShortcutSectionId;
label: string;
keys: ShortcutKey[];
note?: string;
@@ -88,13 +95,22 @@ interface ShortcutBinding {
}
export interface ParsedShortcutBinding extends ShortcutBinding {
parsedCombo: KeyCombo;
parsedChord: KeyCombo[];
}
export interface ChordState {
candidateIndices: number[];
step: number;
timeoutId: ReturnType<typeof setTimeout> | null;
}
// --- Constants ---
const SHORTCUT_HELP_SECTION_TITLES: Record<KeyboardShortcutHelpSection["id"], string> = {
global: "Global",
const SHORTCUT_HELP_SECTION_TITLES: Record<ShortcutSectionId, string> = {
navigation: "Navigation",
"tabs-panes": "Tabs & Panes",
projects: "Projects",
panels: "Panels",
"agent-input": "Agent Input",
};
@@ -109,7 +125,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true },
help: {
id: "new-agent",
section: "global",
section: "projects",
label: "Open project",
keys: ["mod", "shift", "O"],
},
@@ -121,12 +137,64 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, terminal: false },
help: {
id: "new-agent",
section: "global",
section: "projects",
label: "Open project",
keys: ["mod", "shift", "O"],
},
},
// --- New worktree ---
{
id: "worktree-new-cmd-o-mac",
action: "worktree.new",
combo: "Cmd+O",
when: { mac: true, commandCenter: false },
help: {
id: "new-worktree",
section: "projects",
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: "projects",
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: "projects",
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: "projects",
label: "Archive worktree",
keys: ["mod", "shift", "Backspace"],
},
},
// --- Tab management ---
{
id: "workspace-tab-new-cmd-t-mac",
@@ -135,7 +203,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, commandCenter: false },
help: {
id: "workspace-tab-new",
section: "global",
section: "tabs-panes",
label: "New agent tab",
keys: ["mod", "T"],
},
@@ -147,7 +215,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "workspace-tab-new",
section: "global",
section: "tabs-panes",
label: "New agent tab",
keys: ["mod", "T"],
},
@@ -159,7 +227,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, desktop: true, commandCenter: false },
help: {
id: "workspace-tab-close-current",
section: "global",
section: "tabs-panes",
label: "Close current tab",
keys: ["meta", "W"],
},
@@ -171,7 +239,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, desktop: true, commandCenter: false, terminal: false },
help: {
id: "workspace-tab-close-current",
section: "global",
section: "tabs-panes",
label: "Close current tab",
keys: ["ctrl", "W"],
},
@@ -183,7 +251,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { desktop: false, commandCenter: false },
help: {
id: "workspace-tab-close-current",
section: "global",
section: "tabs-panes",
label: "Close current tab",
keys: ["alt", "shift", "W"],
},
@@ -198,7 +266,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "index" },
help: {
id: "workspace-jump-index",
section: "global",
section: "navigation",
label: "Jump to workspace",
keys: ["mod", "1-9"],
},
@@ -211,7 +279,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "index" },
help: {
id: "workspace-jump-index",
section: "global",
section: "navigation",
label: "Jump to workspace",
keys: ["mod", "1-9"],
},
@@ -224,7 +292,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "index" },
help: {
id: "workspace-jump-index",
section: "global",
section: "navigation",
label: "Jump to workspace",
keys: ["alt", "1-9"],
},
@@ -239,7 +307,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "index" },
help: {
id: "workspace-tab-jump-index",
section: "global",
section: "navigation",
label: "Jump to tab",
keys: ["alt", "1-9"],
},
@@ -252,7 +320,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "index" },
help: {
id: "workspace-tab-jump-index",
section: "global",
section: "navigation",
label: "Jump to tab",
keys: ["alt", "shift", "1-9"],
},
@@ -267,7 +335,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: -1 },
help: {
id: "workspace-prev",
section: "global",
section: "navigation",
label: "Previous workspace",
keys: ["mod", "["],
},
@@ -280,7 +348,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: -1 },
help: {
id: "workspace-prev",
section: "global",
section: "navigation",
label: "Previous workspace",
keys: ["mod", "["],
},
@@ -293,7 +361,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: 1 },
help: {
id: "workspace-next",
section: "global",
section: "navigation",
label: "Next workspace",
keys: ["mod", "]"],
},
@@ -306,7 +374,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: 1 },
help: {
id: "workspace-next",
section: "global",
section: "navigation",
label: "Next workspace",
keys: ["mod", "]"],
},
@@ -319,7 +387,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: -1 },
help: {
id: "workspace-prev",
section: "global",
section: "navigation",
label: "Previous workspace",
keys: ["alt", "["],
},
@@ -332,7 +400,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: 1 },
help: {
id: "workspace-next",
section: "global",
section: "navigation",
label: "Next workspace",
keys: ["alt", "]"],
},
@@ -347,7 +415,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: -1 },
help: {
id: "workspace-tab-prev",
section: "global",
section: "navigation",
label: "Previous tab",
keys: ["alt", "shift", "["],
},
@@ -360,7 +428,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: 1 },
help: {
id: "workspace-tab-next",
section: "global",
section: "navigation",
label: "Next tab",
keys: ["alt", "shift", "]"],
},
@@ -374,7 +442,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, terminal: false, commandCenter: false },
help: {
id: "workspace-pane-split-right",
section: "global",
section: "tabs-panes",
label: "Split pane right",
keys: ["mod", "\\"],
},
@@ -386,7 +454,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, terminal: false, commandCenter: false },
help: {
id: "workspace-pane-split-down",
section: "global",
section: "tabs-panes",
label: "Split pane down",
keys: ["mod", "shift", "\\"],
},
@@ -398,7 +466,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, terminal: false, commandCenter: false },
help: {
id: "workspace-pane-focus-left",
section: "global",
section: "tabs-panes",
label: "Focus pane left",
keys: ["mod", "shift", "Left"],
},
@@ -410,7 +478,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, terminal: false, commandCenter: false },
help: {
id: "workspace-pane-focus-right",
section: "global",
section: "tabs-panes",
label: "Focus pane right",
keys: ["mod", "shift", "Right"],
},
@@ -422,7 +490,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, terminal: false, commandCenter: false },
help: {
id: "workspace-pane-focus-up",
section: "global",
section: "tabs-panes",
label: "Focus pane up",
keys: ["mod", "shift", "Up"],
},
@@ -434,7 +502,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, terminal: false, commandCenter: false },
help: {
id: "workspace-pane-focus-down",
section: "global",
section: "tabs-panes",
label: "Focus pane down",
keys: ["mod", "shift", "Down"],
},
@@ -446,7 +514,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, terminal: false, commandCenter: false },
help: {
id: "workspace-pane-move-tab-left",
section: "global",
section: "tabs-panes",
label: "Move tab left",
keys: ["mod", "shift", "alt", "Left"],
},
@@ -458,7 +526,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, terminal: false, commandCenter: false },
help: {
id: "workspace-pane-move-tab-right",
section: "global",
section: "tabs-panes",
label: "Move tab right",
keys: ["mod", "shift", "alt", "Right"],
},
@@ -470,7 +538,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, terminal: false, commandCenter: false },
help: {
id: "workspace-pane-move-tab-up",
section: "global",
section: "tabs-panes",
label: "Move tab up",
keys: ["mod", "shift", "alt", "Up"],
},
@@ -482,7 +550,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, terminal: false, commandCenter: false },
help: {
id: "workspace-pane-move-tab-down",
section: "global",
section: "tabs-panes",
label: "Move tab down",
keys: ["mod", "shift", "alt", "Down"],
},
@@ -494,12 +562,38 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, terminal: false, commandCenter: false },
help: {
id: "workspace-pane-close",
section: "global",
section: "tabs-panes",
label: "Close pane",
keys: ["mod", "shift", "W"],
},
},
// --- 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: "panels",
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: "panels",
label: "New terminal",
keys: ["mod", "shift", "T"],
},
},
// --- Command center ---
{
id: "command-center-toggle-cmd-k-mac",
@@ -508,7 +602,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true },
help: {
id: "toggle-command-center",
section: "global",
section: "panels",
label: "Toggle command center",
keys: ["mod", "K"],
},
@@ -520,7 +614,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, terminal: false },
help: {
id: "toggle-command-center",
section: "global",
section: "panels",
label: "Toggle command center",
keys: ["mod", "K"],
},
@@ -535,7 +629,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { focusScope: "other" },
help: {
id: "show-shortcuts",
section: "global",
section: "panels",
label: "Show keyboard shortcuts",
keys: ["?"],
note: "Available when focus is not in a text field or terminal.",
@@ -550,7 +644,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true },
help: {
id: "toggle-left-sidebar",
section: "global",
section: "panels",
label: "Toggle left sidebar",
keys: ["mod", "B"],
},
@@ -568,7 +662,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "toggle-left-sidebar",
section: "global",
section: "panels",
label: "Toggle left sidebar",
keys: ["mod", "."],
},
@@ -580,7 +674,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, hasSelectedAgent: true, commandCenter: false },
help: {
id: "toggle-right-sidebar",
section: "global",
section: "panels",
label: "Toggle right sidebar",
keys: ["mod", "E"],
},
@@ -592,7 +686,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, hasSelectedAgent: true, commandCenter: false, terminal: false },
help: {
id: "toggle-right-sidebar",
section: "global",
section: "panels",
label: "Toggle right sidebar",
keys: ["mod", "E"],
},
@@ -674,6 +768,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",
@@ -693,11 +833,12 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
// --- Parse bindings at module load ---
function parseBinding(binding: ShortcutBinding): ParsedShortcutBinding {
const parsedCombo = parseShortcutString(binding.combo);
if (binding.repeat === false) {
parsedCombo.repeat = false;
const parsedChord = parseChordString(binding.combo);
const lastCombo = parsedChord.at(-1);
if (binding.repeat === false && lastCombo) {
lastCombo.repeat = false;
}
return { ...binding, parsedCombo };
return { ...binding, parsedChord };
}
export const DEFAULT_BINDINGS: readonly ParsedShortcutBinding[] =
@@ -711,16 +852,17 @@ export function buildEffectiveBindings(
if (override === undefined) {
return binding;
}
let parsedCombo: KeyCombo;
let parsedChord: KeyCombo[];
try {
parsedCombo = parseShortcutString(override);
parsedChord = parseChordString(override);
} catch {
return binding;
}
if (binding.repeat === false) {
parsedCombo.repeat = false;
const lastCombo = parsedChord.at(-1);
if (binding.repeat === false && lastCombo) {
lastCombo.repeat = false;
}
return { ...binding, combo: override, parsedCombo };
return { ...binding, combo: override, parsedChord };
});
}
@@ -797,6 +939,27 @@ function resolvePayload(
}
}
const CHORD_TIMEOUT_MS = 1500;
function clearChordTimeout(timeoutId: ReturnType<typeof setTimeout> | null): void {
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
}
function createChordTimeout(onChordReset: () => void): ReturnType<typeof setTimeout> {
return setTimeout(onChordReset, CHORD_TIMEOUT_MS);
}
function resetChordState(input: ChordState): ChordState {
clearChordTimeout(input.timeoutId);
return {
candidateIndices: [],
step: 0,
timeoutId: null,
};
}
function helpMatchesPlatform(
when: ShortcutWhen | undefined,
context: KeyboardShortcutPlatformContext,
@@ -811,24 +974,131 @@ function helpMatchesPlatform(
export function resolveKeyboardShortcut(input: {
event: KeyboardEvent;
context: KeyboardShortcutContext;
chordState: ChordState;
onChordReset: () => void;
bindings?: readonly ParsedShortcutBinding[];
}): KeyboardShortcutMatch | null {
const { event, context, bindings = DEFAULT_BINDINGS } = input;
for (const binding of bindings) {
if (!matchesCombo(binding.parsedCombo, event, context.isMac)) {
}): {
match: KeyboardShortcutMatch | null;
nextChordState: ChordState;
preventDefault: boolean;
};
export function resolveKeyboardShortcut(input: {
event: KeyboardEvent;
context: KeyboardShortcutContext;
chordState: ChordState;
onChordReset: () => void;
bindings?: readonly ParsedShortcutBinding[];
}): {
match: KeyboardShortcutMatch | null;
nextChordState: ChordState;
preventDefault: boolean;
} {
const { event, context, chordState, onChordReset, bindings = DEFAULT_BINDINGS } = input;
if (chordState.step === 0) {
const advancingCandidateIndices: number[] = [];
let singleComboMatch: KeyboardShortcutMatch | null = null;
for (const [index, binding] of bindings.entries()) {
const firstCombo = binding.parsedChord[0];
if (!firstCombo) {
continue;
}
if (!matchesCombo(firstCombo, event, context.isMac)) {
continue;
}
if (!matchesWhen(binding.when, context)) {
continue;
}
if (binding.parsedChord.length > 1) {
advancingCandidateIndices.push(index);
continue;
}
if (!singleComboMatch) {
singleComboMatch = {
action: binding.action,
payload: resolvePayload(binding.payload, event),
preventDefault: binding.preventDefault ?? true,
stopPropagation: binding.stopPropagation ?? true,
};
}
}
if (advancingCandidateIndices.length > 0) {
return {
match: null,
nextChordState: {
candidateIndices: advancingCandidateIndices,
step: 1,
timeoutId: createChordTimeout(onChordReset),
},
preventDefault: true,
};
}
return {
match: singleComboMatch,
nextChordState: resetChordState(chordState),
preventDefault: false,
};
}
const matchingCandidateIndices: number[] = [];
let completedMatch: KeyboardShortcutMatch | null = null;
for (const index of chordState.candidateIndices) {
const binding = bindings[index];
if (!binding) {
continue;
}
const combo = binding.parsedChord[chordState.step];
if (!combo) {
continue;
}
if (!matchesCombo(combo, event, context.isMac)) {
continue;
}
if (!matchesWhen(binding.when, context)) {
continue;
}
if (chordState.step + 1 === binding.parsedChord.length) {
completedMatch = {
action: binding.action,
payload: resolvePayload(binding.payload, event),
preventDefault: binding.preventDefault ?? true,
stopPropagation: binding.stopPropagation ?? true,
};
break;
}
matchingCandidateIndices.push(index);
}
if (completedMatch) {
return {
action: binding.action,
payload: resolvePayload(binding.payload, event),
preventDefault: binding.preventDefault ?? true,
stopPropagation: binding.stopPropagation ?? true,
match: completedMatch,
nextChordState: resetChordState(chordState),
preventDefault: false,
};
}
return null;
if (matchingCandidateIndices.length > 0) {
clearChordTimeout(chordState.timeoutId);
return {
match: null,
nextChordState: {
candidateIndices: matchingCandidateIndices,
step: chordState.step + 1,
timeoutId: createChordTimeout(onChordReset),
},
preventDefault: true,
};
}
return {
match: null,
nextChordState: resetChordState(chordState),
preventDefault: false,
};
}
export function getBindingIdForAction(
@@ -868,8 +1138,11 @@ export function buildKeyboardShortcutHelpSections(
bindings: readonly ParsedShortcutBinding[] = DEFAULT_BINDINGS,
): KeyboardShortcutHelpSection[] {
const seenRows = new Set<string>();
const rowsBySection = new Map<KeyboardShortcutHelpSection["id"], KeyboardShortcutHelpRow[]>([
["global", []],
const rowsBySection = new Map<ShortcutSectionId, KeyboardShortcutHelpRow[]>([
["navigation", []],
["tabs-panes", []],
["projects", []],
["panels", []],
["agent-input", []],
]);
@@ -899,7 +1172,7 @@ export function buildKeyboardShortcutHelpSections(
});
}
const sectionOrder: KeyboardShortcutHelpSection["id"][] = ["global", "agent-input"];
const sectionOrder: ShortcutSectionId[] = ["navigation", "tabs-panes", "projects", "panels", "agent-input"];
return sectionOrder.flatMap((sectionId) => {
const rows = rowsBySection.get(sectionId) ?? [];
@@ -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" };
@@ -101,6 +103,10 @@ export function parseShortcutString(s: string): KeyCombo {
return combo;
}
export function parseChordString(s: string): KeyCombo[] {
return s.split(" ").map(parseShortcutString);
}
export function keyComboToString(combo: KeyCombo): string {
const parts: string[] = [];
@@ -120,6 +126,10 @@ export function keyComboToString(combo: KeyCombo): string {
return parts.join("+");
}
export function chordToString(chord: KeyCombo[]): string {
return chord.map(keyComboToString).join(" ");
}
const MODIFIER_CODES = new Set([
"MetaLeft",
"MetaRight",
@@ -156,6 +166,10 @@ export function comboStringToShortcutKeys(comboString: string): ShortcutKey[] {
return keys;
}
export function chordStringToShortcutKeys(s: string): ShortcutKey[][] {
return s.split(" ").map(comboStringToShortcutKeys);
}
export function keyboardEventToComboString(event: KeyboardEvent): string | null {
if (MODIFIER_CODES.has(event.code)) {
return null;
+2 -10
View File
@@ -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() {
<TerminalPane
serverId={serverId}
cwd={workspaceId}
selectedTerminalId={target.terminalId}
onSelectedTerminalIdChange={(terminalId) => {
if (!terminalId) {
return;
}
openTab({ kind: "terminal", terminalId });
}}
hideHeader
manageTerminalDirectorySubscription={false}
terminalId={target.terminalId}
/>
);
}
+3 -3
View File
@@ -67,7 +67,7 @@ import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
type SettingsSectionId =
| "hosts"
| "appearance"
| "keyboard-shortcuts"
| "shortcuts"
| "diagnostics"
| "about"
| "permissions"
@@ -83,7 +83,7 @@ function getSettingsSections(context: { isDesktop: boolean }): SettingsSectionDe
const sections: SettingsSectionDef[] = [
{ id: "hosts", label: "Hosts", icon: Server },
{ id: "appearance", label: "Appearance", icon: Palette },
{ id: "keyboard-shortcuts", label: "Keyboard Shortcuts", icon: Keyboard },
{ id: "shortcuts", label: "Shortcuts", icon: Keyboard },
{ id: "diagnostics", label: "Diagnostics", icon: Stethoscope },
{ id: "about", label: "About", icon: Info },
];
@@ -514,7 +514,7 @@ function SettingsSectionContent({
return <HostsSection {...hostsProps} />;
case "appearance":
return <AppearanceSection {...appearanceProps} />;
case "keyboard-shortcuts":
case "shortcuts":
return <KeyboardShortcutsSection />;
case "diagnostics":
return <DiagnosticsSection {...diagnosticsProps} />;
@@ -10,16 +10,31 @@ import {
getBindingIdForAction,
type KeyboardShortcutHelpRow,
} from "@/keyboard/keyboard-shortcuts";
import { comboStringToShortcutKeys, keyboardEventToComboString } from "@/keyboard/shortcut-string";
import {
chordStringToShortcutKeys,
comboStringToShortcutKeys,
keyboardEventToComboString,
} from "@/keyboard/shortcut-string";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { getIsDesktop } from "@/constants/layout";
function ShortcutSequence({ chord }: { chord: string[] | null }) {
if (!chord || chord.length === 0) {
return <Text style={styles.capturingText}>Press shortcut...</Text>;
}
return <Shortcut chord={chord.map(comboStringToShortcutKeys)} />;
}
function ShortcutRow({
row,
bindingId,
overrideCombo,
isCapturing,
capturedCombos,
onRebind,
onDone,
onCancel,
onReset,
}: {
@@ -27,29 +42,38 @@ function ShortcutRow({
bindingId: string | null;
overrideCombo: string | undefined;
isCapturing: boolean;
capturedCombos: string[];
onRebind: () => void;
onDone: () => void;
onCancel: () => void;
onReset: () => void;
}) {
const displayKeys = overrideCombo ? comboStringToShortcutKeys(overrideCombo) : row.keys;
const displayChord = overrideCombo ? chordStringToShortcutKeys(overrideCombo) : [row.keys];
return (
<View style={[styles.row, isCapturing && styles.rowCapturing]}>
<Text style={styles.rowLabel}>{row.label}</Text>
<View style={styles.rowActions}>
{isCapturing ? (
<Text style={styles.capturingText}>Press shortcut...</Text>
<ShortcutSequence chord={capturedCombos} />
) : (
<Shortcut keys={displayKeys} />
<Shortcut chord={displayChord} />
)}
{bindingId !== null && (
<Button
variant="ghost"
size="sm"
onPress={isCapturing ? onCancel : onRebind}
>
{isCapturing ? "Cancel" : "Rebind"}
</Button>
<>
{isCapturing && capturedCombos.length > 0 ? (
<Button variant="ghost" size="sm" onPress={onDone}>
Done
</Button>
) : null}
<Button
variant="ghost"
size="sm"
onPress={isCapturing ? onCancel : onRebind}
>
{isCapturing ? "Cancel" : "Rebind"}
</Button>
</>
)}
{overrideCombo !== undefined && !isCapturing && (
<Button variant="ghost" size="sm" onPress={onReset}>
@@ -63,51 +87,73 @@ function ShortcutRow({
export function KeyboardShortcutsSection() {
const [capturingBindingId, setCapturingBindingId] = useState<string | null>(null);
const [capturedCombos, setCapturedCombos] = useState<string[]>([]);
const { overrides, hasOverrides, setOverride, removeOverride, resetAll } =
useKeyboardShortcutOverrides();
const setCapturingShortcut = useKeyboardShortcutsStore((s) => s.setCapturingShortcut);
const isMac = getShortcutOs() === "mac";
const isDesktop = getIsDesktop();
const sections = buildKeyboardShortcutHelpSections({ isMac, isDesktop });
function cancelCapture() {
setCapturedCombos([]);
setCapturingBindingId(null);
setCapturingShortcut(false);
}
function startCapture(bindingId: string) {
setCapturedCombos([]);
setCapturingBindingId(bindingId);
setCapturingShortcut(true);
}
function saveCapture() {
if (capturingBindingId === null || capturedCombos.length === 0) {
return;
}
void setOverride(capturingBindingId, capturedCombos.join(" "));
cancelCapture();
}
useEffect(() => {
if (Platform.OS !== "web") return;
if (capturingBindingId === null) return;
const activeBindingId = capturingBindingId;
function handleKeyDown(event: KeyboardEvent) {
const key = event.key ?? "";
if (key === "Escape") {
event.preventDefault();
event.stopPropagation();
setCapturingBindingId(null);
return;
}
event.preventDefault();
event.stopPropagation();
if (key === "Alt" || key === "Control" || key === "Meta" || key === "Shift") {
const key = event.key ?? "";
if (key === "Backspace") {
setCapturedCombos((current) => (current.length > 0 ? current.slice(0, -1) : current));
return;
}
const comboString = keyboardEventToComboString(event);
if (comboString === null) return;
if (comboString === null) {
return;
}
event.preventDefault();
event.stopPropagation();
void setOverride(activeBindingId, comboString);
setCapturingBindingId(null);
setCapturedCombos((current) => [...current, comboString]);
}
window.addEventListener("keydown", handleKeyDown, true);
return () => {
window.removeEventListener("keydown", handleKeyDown, true);
};
}, [capturingBindingId, setOverride]);
}, [capturingBindingId]);
useEffect(() => {
return () => {
setCapturingShortcut(false);
};
}, [setCapturingShortcut]);
if (Platform.OS !== "web") {
return (
<View style={settingsStyles.section}>
<Text style={settingsStyles.sectionTitle}>Keyboard Shortcuts</Text>
<Text style={settingsStyles.sectionTitle}>Shortcuts</Text>
<View style={[settingsStyles.card, styles.mobileCard]}>
<Text style={styles.mobileText}>
Keyboard shortcuts are only available on desktop.
@@ -120,7 +166,7 @@ export function KeyboardShortcutsSection() {
return (
<View style={settingsStyles.section}>
<View style={styles.sectionHeader}>
<Text style={settingsStyles.sectionTitle}>Keyboard Shortcuts</Text>
<Text style={settingsStyles.sectionTitle}>Shortcuts</Text>
{hasOverrides && (
<Button variant="ghost" size="sm" onPress={() => void resetAll()}>
Reset all
@@ -144,8 +190,14 @@ export function KeyboardShortcutsSection() {
bindingId={bindingId}
overrideCombo={overrideCombo}
isCapturing={capturingBindingId === bindingId}
onRebind={() => setCapturingBindingId(bindingId)}
onCancel={() => setCapturingBindingId(null)}
capturedCombos={capturingBindingId === bindingId ? capturedCombos : []}
onRebind={() => {
if (bindingId) {
startCapture(bindingId);
}
}}
onDone={saveCapture}
onCancel={cancelCapture}
onReset={() => {
if (bindingId) void removeOverride(bindingId);
}}
@@ -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<number>(0);
const [tabsActionsWidth, setTabsActionsWidth] = useState<number>(0);
@@ -448,10 +453,12 @@ export function WorkspaceDesktopTabsRow({
>
<SquarePen size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger>
<TooltipContent side="bottom" align="end" offset={8}>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>New agent tab</Text>
<Shortcut keys={["mod", "T"]} style={styles.newTabTooltipShortcut} />
{newAgentTabKeys ? (
<Shortcut chord={newAgentTabKeys} style={styles.newTabTooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
@@ -467,8 +474,13 @@ export function WorkspaceDesktopTabsRow({
>
<SquareTerminal size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger>
<TooltipContent side="bottom" align="end" offset={8}>
<Text style={styles.newTabTooltipText}>New terminal tab</Text>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>New terminal tab</Text>
{newTerminalTabKeys ? (
<Shortcut chord={newTerminalTabKeys} style={styles.newTabTooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
@@ -483,8 +495,13 @@ export function WorkspaceDesktopTabsRow({
>
<Columns2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger>
<TooltipContent side="bottom" align="end" offset={8}>
<Text style={styles.newTabTooltipText}>Split pane right</Text>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>Split pane right</Text>
{splitRightKeys ? (
<Shortcut chord={splitRightKeys} style={styles.newTabTooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
@@ -499,8 +516,13 @@ export function WorkspaceDesktopTabsRow({
>
<Rows2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger>
<TooltipContent side="bottom" align="end" offset={8}>
<Text style={styles.newTabTooltipText}>Split pane down</Text>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>Split pane down</Text>
{splitDownKeys ? (
<Shortcut chord={splitDownKeys} style={styles.newTabTooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
</View>
@@ -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,
@@ -4,7 +4,9 @@ import { useKeyboardShortcutsStore } from "./keyboard-shortcuts-store";
beforeEach(() => {
useKeyboardShortcutsStore.setState({
commandCenterOpen: false,
projectPickerOpen: false,
shortcutsDialogOpen: false,
capturingShortcut: false,
altDown: false,
cmdOrCtrlDown: false,
sidebarShortcutWorkspaceTargets: [],
@@ -18,4 +20,10 @@ describe("keyboard-shortcuts-store", () => {
useKeyboardShortcutsStore.getState().setCommandCenterOpen(true);
expect(useKeyboardShortcutsStore.getState().commandCenterOpen).toBe(true);
});
it("toggles shortcut capture state", () => {
expect(useKeyboardShortcutsStore.getState().capturingShortcut).toBe(false);
useKeyboardShortcutsStore.getState().setCapturingShortcut(true);
expect(useKeyboardShortcutsStore.getState().capturingShortcut).toBe(true);
});
});
@@ -5,6 +5,7 @@ interface KeyboardShortcutsState {
commandCenterOpen: boolean;
projectPickerOpen: boolean;
shortcutsDialogOpen: boolean;
capturingShortcut: boolean;
altDown: boolean;
cmdOrCtrlDown: boolean;
/** Sidebar-visible workspace targets (up to 9), in top-to-bottom visual order. */
@@ -15,6 +16,7 @@ interface KeyboardShortcutsState {
setCommandCenterOpen: (open: boolean) => void;
setProjectPickerOpen: (open: boolean) => void;
setShortcutsDialogOpen: (open: boolean) => void;
setCapturingShortcut: (capturing: boolean) => void;
setAltDown: (down: boolean) => void;
setCmdOrCtrlDown: (down: boolean) => void;
setSidebarShortcutWorkspaceTargets: (targets: SidebarShortcutWorkspaceTarget[]) => void;
@@ -26,6 +28,7 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>((set) =>
commandCenterOpen: false,
projectPickerOpen: false,
shortcutsDialogOpen: false,
capturingShortcut: false,
altDown: false,
cmdOrCtrlDown: false,
sidebarShortcutWorkspaceTargets: [],
@@ -34,6 +37,7 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>((set) =>
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
setProjectPickerOpen: (open) => set({ projectPickerOpen: open }),
setShortcutsDialogOpen: (open) => set({ shortcutsDialogOpen: open }),
setCapturingShortcut: (capturing) => set({ capturingShortcut: capturing }),
setAltDown: (down) => set({ altDown: down }),
setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }),
setSidebarShortcutWorkspaceTargets: (targets) =>
+12
View File
@@ -2,8 +2,20 @@ export type ShortcutKey = "mod" | "shift" | "alt" | "ctrl" | "meta" | string;
export type ShortcutOs = "mac" | "non-mac";
const KEY_DISPLAY: Record<string, string> = {
Backspace: "⌫",
Enter: "↩",
Esc: "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;
}
+18 -4
View File
@@ -1,7 +1,23 @@
#!/bin/sh
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
SCRIPT_PATH=$0
case "$SCRIPT_PATH" in
/*) ;;
*) SCRIPT_PATH="$(pwd)/$SCRIPT_PATH" ;;
esac
# Follow symlinks so `/usr/local/bin/paseo` resolves back into the app bundle.
while [ -L "$SCRIPT_PATH" ]; do
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$SCRIPT_PATH")" && pwd)
LINK_TARGET=$(readlink "$SCRIPT_PATH")
case "$LINK_TARGET" in
/*) SCRIPT_PATH="$LINK_TARGET" ;;
*) SCRIPT_PATH="${SCRIPT_DIR}/${LINK_TARGET}" ;;
esac
done
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$SCRIPT_PATH")" && pwd)
RESOURCES_DIR=$(CDPATH= cd -- "${SCRIPT_DIR}/.." && pwd)
# macOS: MacOS/Paseo is a sibling of Resources/
@@ -15,6 +31,4 @@ else
exit 1
fi
CLI_ENTRY="${RESOURCES_DIR}/app.asar/node_modules/@getpaseo/cli/dist/index.js"
exec env ELECTRON_RUN_AS_NODE=1 "${APP_EXECUTABLE}" "${CLI_ENTRY}" "$@"
exec env PASEO_DESKTOP_CLI=1 "${APP_EXECUTABLE}" "$@"
+2 -4
View File
@@ -4,12 +4,10 @@ setlocal
set "SCRIPT_DIR=%~dp0"
set "RESOURCES_DIR=%SCRIPT_DIR%.."
set "APP_EXECUTABLE=%RESOURCES_DIR%\..\Paseo.exe"
set "CLI_ENTRY=%RESOURCES_DIR%\app.asar\node_modules\@getpaseo\cli\dist\index.js"
if not exist "%APP_EXECUTABLE%" (
echo Bundled Paseo executable not found at %APP_EXECUTABLE% 1>&2
exit /b 1
)
set "ELECTRON_RUN_AS_NODE=1"
"%APP_EXECUTABLE%" "%CLI_ENTRY%" %*
set "PASEO_DESKTOP_CLI=1"
"%APP_EXECUTABLE%" %*
+1
View File
@@ -8,6 +8,7 @@
"build": "npm --prefix ../.. run build:daemon && npm run build:main && electron-builder --config electron-builder.yml",
"build:main": "tsc -p tsconfig.json",
"dev": "npm run build:main && wait-on tcp:8081 && electron .",
"test": "vitest run",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
+14 -8
View File
@@ -16,7 +16,7 @@ import {
sendLocalTransportMessage,
closeLocalTransportSession,
} from "./local-transport.js";
import { createElectronNodeEnv, resolveDaemonRunnerEntrypoint } from "./runtime-paths.js";
import { createNodeEntrypointInvocation, resolveDaemonRunnerEntrypoint } from "./runtime-paths.js";
const DAEMON_LOG_FILENAME = "daemon.log";
const DAEMON_PID_FILENAME = "paseo.pid";
@@ -293,17 +293,23 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
const home = getPaseoHome();
const daemonRunner = resolveDaemonRunnerEntrypoint();
const corsOrigins = buildDesktopDaemonCorsOriginsEnv();
const invocation = createNodeEntrypointInvocation({
entrypoint: daemonRunner,
argvMode: "node-script",
args: [],
baseEnv: {
...process.env,
PASEO_HOME: home,
...(corsOrigins ? { PASEO_CORS_ORIGINS: corsOrigins } : {}),
},
});
const child: ChildProcess = spawn(
process.execPath,
[...daemonRunner.execArgv, daemonRunner.entryPath],
invocation.command,
invocation.args,
{
detached: true,
env: createElectronNodeEnv({
...process.env,
PASEO_HOME: home,
...(corsOrigins ? { PASEO_CORS_ORIGINS: corsOrigins } : {}),
}),
env: invocation.env,
stdio: ["ignore", "ignore", "ignore"],
},
);
@@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import {
createNodeEntrypointInvocation,
parseCliPassthroughArgsFromArgv,
type NodeEntrypointSpec,
} from "./node-entrypoint-launcher";
const CLI_ENTRYPOINT: NodeEntrypointSpec = {
entryPath: "/tmp/paseo-cli.js",
execArgv: ["--import", "tsx"],
};
describe("node-entrypoint-launcher", () => {
describe("parseCliPassthroughArgsFromArgv", () => {
it("returns null when no CLI args are provided", () => {
expect(
parseCliPassthroughArgsFromArgv({
argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo"],
isDefaultApp: false,
forceCli: false,
}),
).toBeNull();
});
it("ignores macOS GUI launch arguments", () => {
expect(
parseCliPassthroughArgsFromArgv({
argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo", "-psn_0_12345"],
isDefaultApp: false,
forceCli: false,
}),
).toBeNull();
});
it("preserves CLI flags for direct app invocations", () => {
expect(
parseCliPassthroughArgsFromArgv({
argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo", "--version"],
isDefaultApp: false,
forceCli: false,
}),
).toEqual(["--version"]);
});
it("forces CLI mode for shim launches even without args", () => {
expect(
parseCliPassthroughArgsFromArgv({
argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo"],
isDefaultApp: false,
forceCli: true,
}),
).toEqual([]);
});
});
describe("createNodeEntrypointInvocation", () => {
it("uses the packaged runner when the desktop app is packaged", () => {
expect(
createNodeEntrypointInvocation({
execPath: "/Applications/Paseo.app/Contents/MacOS/Paseo",
isPackaged: true,
packagedRunnerPath: "/Applications/Paseo.app/Contents/Resources/app.asar/dist/daemon/node-entrypoint-runner.js",
entrypoint: CLI_ENTRYPOINT,
argvMode: "bare",
args: ["ls", "--json"],
baseEnv: { PATH: "/usr/bin" },
}),
).toEqual({
command: "/Applications/Paseo.app/Contents/MacOS/Paseo",
args: [
"/Applications/Paseo.app/Contents/Resources/app.asar/dist/daemon/node-entrypoint-runner.js",
"bare",
"/tmp/paseo-cli.js",
"ls",
"--json",
],
env: {
PATH: "/usr/bin",
ELECTRON_RUN_AS_NODE: "1",
},
});
});
it("uses the entrypoint directly in development", () => {
expect(
createNodeEntrypointInvocation({
execPath: "/opt/homebrew/bin/electron",
isPackaged: false,
packagedRunnerPath: null,
entrypoint: CLI_ENTRYPOINT,
argvMode: "bare",
args: ["ls"],
baseEnv: { PATH: "/usr/bin" },
}),
).toEqual({
command: "/opt/homebrew/bin/electron",
args: ["--import", "tsx", "/tmp/paseo-cli.js", "ls"],
env: {
PATH: "/usr/bin",
ELECTRON_RUN_AS_NODE: "1",
},
});
});
it("keeps node-style argv for packaged script entrypoints", () => {
expect(
createNodeEntrypointInvocation({
execPath: "/Applications/Paseo.app/Contents/MacOS/Paseo",
isPackaged: true,
packagedRunnerPath: "/Applications/Paseo.app/Contents/Resources/app.asar/dist/daemon/node-entrypoint-runner.js",
entrypoint: CLI_ENTRYPOINT,
argvMode: "node-script",
args: ["--dev"],
baseEnv: { PATH: "/usr/bin" },
}),
).toEqual({
command: "/Applications/Paseo.app/Contents/MacOS/Paseo",
args: [
"/Applications/Paseo.app/Contents/Resources/app.asar/dist/daemon/node-entrypoint-runner.js",
"node-script",
"/tmp/paseo-cli.js",
"--dev",
],
env: {
PATH: "/usr/bin",
ELECTRON_RUN_AS_NODE: "1",
},
});
});
});
});
@@ -0,0 +1,78 @@
const IGNORED_GUI_LAUNCH_ARG_PREFIX = "-psn_";
export const DESKTOP_CLI_ENV = "PASEO_DESKTOP_CLI";
export type NodeEntrypointSpec = {
entryPath: string;
execArgv: string[];
};
export type NodeEntrypointInvocation = {
command: string;
args: string[];
env: NodeJS.ProcessEnv;
};
export type NodeEntrypointArgvMode = "bare" | "node-script";
type CreateNodeEntrypointInvocationInput = {
execPath: string;
isPackaged: boolean;
packagedRunnerPath: string | null;
entrypoint: NodeEntrypointSpec;
argvMode: NodeEntrypointArgvMode;
args: string[];
baseEnv: NodeJS.ProcessEnv;
};
type ParseCliPassthroughArgsFromArgvInput = {
argv: string[];
isDefaultApp: boolean;
forceCli: boolean;
};
export function createElectronNodeEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return {
...baseEnv,
ELECTRON_RUN_AS_NODE: "1",
};
}
export function parseCliPassthroughArgsFromArgv(
input: ParseCliPassthroughArgsFromArgvInput,
): string[] | null {
const startIndex = input.isDefaultApp ? 2 : 1;
const effective = input.argv
.slice(startIndex)
.filter((arg) => !arg.startsWith(IGNORED_GUI_LAUNCH_ARG_PREFIX));
if (input.forceCli) {
return effective;
}
return effective.length > 0 ? effective : null;
}
export function createNodeEntrypointInvocation(
input: CreateNodeEntrypointInvocationInput,
): NodeEntrypointInvocation {
const env = createElectronNodeEnv(input.baseEnv);
if (input.isPackaged) {
if (!input.packagedRunnerPath) {
throw new Error("Packaged node entrypoint runner is required for desktop launches.");
}
return {
command: input.execPath,
args: [input.packagedRunnerPath, input.argvMode, input.entrypoint.entryPath, ...input.args],
env,
};
}
return {
command: input.execPath,
args: [...input.entrypoint.execArgv, input.entrypoint.entryPath, ...input.args],
env,
};
}
@@ -0,0 +1,21 @@
import { pathToFileURL } from "node:url";
async function main(): Promise<void> {
const [argvMode, entryPath, ...args] = process.argv.slice(2);
if (argvMode !== "bare" && argvMode !== "node-script") {
throw new Error(`Unsupported node entrypoint argv mode: ${argvMode ?? "<missing>"}`);
}
if (!entryPath) {
throw new Error("Missing node entrypoint path.");
}
process.argv =
argvMode === "bare" ? [process.argv[0] ?? "node", ...args] : [process.argv[0] ?? "node", entryPath, ...args];
await import(pathToFileURL(entryPath).href);
}
void main().catch((error) => {
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
process.stderr.write(`${message}\n`);
process.exit(1);
});
+45 -23
View File
@@ -3,21 +3,23 @@ import { spawnSync, type SpawnSyncReturns } from "node:child_process";
import { createRequire } from "node:module";
import path from "node:path";
import { app } from "electron";
import {
DESKTOP_CLI_ENV,
createNodeEntrypointInvocation as createSharedNodeEntrypointInvocation,
parseCliPassthroughArgsFromArgv as parseCliPassthroughArgs,
type NodeEntrypointArgvMode,
type NodeEntrypointInvocation,
type NodeEntrypointSpec,
} from "./node-entrypoint-launcher.js";
const CLI_PACKAGE_NAME = "@getpaseo/cli";
const SERVER_PACKAGE_NAME = "@getpaseo/server";
const CLI_BIN_ENTRY = `${CLI_PACKAGE_NAME}/bin/paseo`;
const IGNORED_GUI_LAUNCH_ARG_PREFIX = "-psn_";
type PackageInfo = {
root: string;
};
export type NodeEntrypointSpec = {
entryPath: string;
execArgv: string[];
};
const esmRequire = createRequire(__filename);
function findPackageRootFromResolvedPath(input: {
@@ -73,6 +75,10 @@ function resolvePackagedAsarPath(): string {
return path.join(process.resourcesPath, "app.asar");
}
function resolvePackagedNodeEntrypointRunnerPath(): string {
return path.join(resolvePackagedAsarPath(), "dist", "daemon", "node-entrypoint-runner.js");
}
function assertPathExists(input: { label: string; filePath: string }): string {
if (!existsSync(input.filePath)) {
throw new Error(`${input.label} is missing at ${input.filePath}`);
@@ -82,16 +88,11 @@ function assertPathExists(input: { label: string; filePath: string }): string {
}
export function parseCliPassthroughArgsFromArgv(argv: string[]): string[] | null {
const startIndex = process.defaultApp ? 2 : 1;
const effective = argv
.slice(startIndex)
.filter(
(arg) =>
!arg.startsWith(IGNORED_GUI_LAUNCH_ARG_PREFIX) &&
!arg.startsWith("--"),
);
return effective.length > 0 ? effective : null;
return parseCliPassthroughArgs({
argv,
isDefaultApp: process.defaultApp,
forceCli: process.env[DESKTOP_CLI_ENV] === "1",
});
}
export function resolveDaemonRunnerEntrypoint(): NodeEntrypointSpec {
@@ -167,18 +168,39 @@ export function resolveCliEntrypoint(): NodeEntrypointSpec {
};
}
export function createElectronNodeEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return {
...baseEnv,
ELECTRON_RUN_AS_NODE: "1",
};
export function createNodeEntrypointInvocation(input: {
entrypoint: NodeEntrypointSpec;
argvMode: NodeEntrypointArgvMode;
args: string[];
baseEnv: NodeJS.ProcessEnv;
}): NodeEntrypointInvocation {
return createSharedNodeEntrypointInvocation({
execPath: process.execPath,
isPackaged: app.isPackaged,
packagedRunnerPath: app.isPackaged
? assertPathExists({
label: "Bundled node entrypoint runner",
filePath: resolvePackagedNodeEntrypointRunnerPath(),
})
: null,
entrypoint: input.entrypoint,
argvMode: input.argvMode,
args: input.args,
baseEnv: input.baseEnv,
});
}
function spawnCliProcess(args: string[]): SpawnSyncReturns<Buffer> {
const cli = resolveCliEntrypoint();
const invocation = createNodeEntrypointInvocation({
entrypoint: cli,
argvMode: "bare",
args,
baseEnv: process.env,
});
return spawnSync(process.execPath, [...cli.execArgv, cli.entryPath, ...args], {
env: createElectronNodeEnv(process.env),
return spawnSync(invocation.command, invocation.args, {
env: invocation.env,
stdio: "inherit",
});
}
+2 -1
View File
@@ -5,8 +5,9 @@
"main": "build/index.js",
"types": "build/index.d.ts",
"scripts": {
"build": "expo-module build",
"build": "tsc",
"clean": "expo-module clean",
"dev": "expo-module build",
"lint": "expo-module lint",
"test": "expo-module test",
"format": "biome check .",
+1 -1
View File
@@ -10,7 +10,7 @@ dotenv.config({
const daemonRunnerEntry = fileURLToPath(new URL("./daemon-runner.ts", import.meta.url));
const result = spawnSync(
process.execPath,
[...process.execArgv, daemonRunnerEntry, "--dev", ...process.argv.slice(2)],
["--inspect", "--heapsnapshot-near-heap-limit=3", ...process.execArgv, daemonRunnerEntry, "--dev", ...process.argv.slice(2)],
{
stdio: "inherit",
env: process.env,