Compare commits

..
54 changed files with 1585 additions and 2011 deletions
-74
View File
@@ -1,74 +0,0 @@
# 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,7 +25,6 @@
},
"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",
@@ -15185,31 +15184,6 @@
"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",
@@ -32573,16 +32547,6 @@
"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",
+1 -2
View File
@@ -14,7 +14,7 @@
],
"scripts": {
"dev": "./scripts/dev.sh",
"dev:server": "npm run dev --workspace=@getpaseo/server",
"dev:server": "NODE_ENV=development tsx packages/server/scripts/daemon-runner.ts --dev",
"dev:app": "npm run start --workspace=@getpaseo/app",
"dev:website": "npm run dev --workspace=@getpaseo/website",
"postinstall": "node scripts/postinstall-patches.mjs",
@@ -59,7 +59,6 @@
},
"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 --workspace=@getpaseo/expo-two-way-audio",
"build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --prefix ../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,7 +120,6 @@ export function AgentInputArea({
const toast = useToast();
const voice = useVoiceOptional();
const voiceToggleKeys = useShortcutKeys("voice-toggle");
const dictationCancelKeys = useShortcutKeys("dictation-cancel");
const isDictationReady =
isConnected &&
(agentDirectoryStatus === "ready" ||
@@ -594,14 +593,12 @@ export function AgentInputArea({
)}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Interrupt</Text>
{dictationCancelKeys ? (
<Shortcut chord={dictationCancelKeys} style={styles.tooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Interrupt</Text>
<Shortcut keys={["Esc"]} style={styles.tooltipShortcut} />
</View>
</TooltipContent>
</Tooltip>
) : null;
const rightContent = (
@@ -628,9 +625,7 @@ export function AgentInputArea({
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Voice mode</Text>
{voiceToggleKeys ? (
<Shortcut chord={voiceToggleKeys} style={styles.tooltipShortcut} />
) : null}
{voiceToggleKeys && <Shortcut keys={voiceToggleKeys} style={styles.tooltipShortcut} />}
</View>
</TooltipContent>
</Tooltip>
@@ -178,7 +178,7 @@ export function CommandCenter() {
</View>
</View>
{action.shortcutKeys ? (
<Shortcut chord={action.shortcutKeys} style={styles.rowShortcut} />
<Shortcut keys={action.shortcutKeys} style={styles.rowShortcut} />
) : null}
</View>
</CommandCenterRow>
@@ -9,8 +9,6 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import type { GitAction, GitActions } from "@/components/git-actions-policy";
interface GitActionsSplitButtonProps {
@@ -19,7 +17,6 @@ interface GitActionsSplitButtonProps {
export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps) {
const { theme } = useUnistyles();
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const getActionDisplayLabel = useCallback((action: GitAction): string => {
if (action.status === "pending") return action.pendingLabel;
@@ -80,11 +77,6 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
<DropdownMenuItem
testID={`changes-menu-${action.id}`}
leading={action.icon}
trailing={
action.id === "archive-worktree" && archiveShortcutKeys
? <Shortcut chord={archiveShortcutKeys} />
: undefined
}
disabled={action.disabled}
status={action.status}
pendingLabel={action.pendingLabel}
@@ -21,7 +21,7 @@ export function KeyboardShortcutsDialog() {
return (
<AdaptiveModalSheet
title="Shortcuts"
title="Keyboard 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 chord={newAgentKeys} /> : null}
{newAgentKeys && <Shortcut keys={newAgentKeys} />}
</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>
{newAgentKeys ? <Shortcut chord={newAgentKeys} /> : null}
<Shortcut keys={["⌘", "⇧", "O"]} />
</View>
</TooltipContent>
</Tooltip>
+6 -19
View File
@@ -33,7 +33,6 @@ 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,
@@ -211,10 +210,6 @@ 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);
@@ -1011,16 +1006,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
: "Mute voice"
: "Dictation"}
</Text>
{(isRealtimeVoiceForCurrentAgent ? voiceMuteToggleKeys : dictationToggleKeys) ? (
<Shortcut
chord={
(isRealtimeVoiceForCurrentAgent
? voiceMuteToggleKeys
: dictationToggleKeys)!
}
style={styles.tooltipShortcut}
/>
) : null}
<Shortcut
keys={isRealtimeVoiceForCurrentAgent ? ["Space"] : ["mod", "D"]}
style={styles.tooltipShortcut}
/>
</View>
</TooltipContent>
</Tooltip>
@@ -1043,9 +1032,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Queue</Text>
{queueKeys ? (
<Shortcut chord={queueKeys} style={styles.tooltipShortcut} />
) : null}
<Shortcut keys={["mod", "Enter"]} style={styles.tooltipShortcut} />
</View>
</TooltipContent>
</Tooltip>
@@ -1068,7 +1055,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>
{sendKeys ? <Shortcut chord={sendKeys} style={styles.tooltipShortcut} /> : null}
<Shortcut keys={["Enter"]} style={styles.tooltipShortcut} />
</View>
</TooltipContent>
</Tooltip>
@@ -27,12 +27,12 @@ import { type GestureType } from "react-native-gesture-handler";
import * as Clipboard from "expo-clipboard";
import {
Archive,
Check,
ChevronDown,
ChevronRight,
CircleHelp,
Copy,
ExternalLink,
FolderGit2,
GitPullRequest,
Monitor,
MoreVertical,
Plus,
@@ -48,6 +48,7 @@ import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import {
type SidebarProjectEntry,
type SidebarWorkspaceEntry,
type SidebarStateBucket,
} from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
@@ -72,19 +73,12 @@ import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
import { confirmDialog } from "@/utils/confirm-dialog";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import { getStatusDotColor } from "@/utils/status-dot-color";
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 { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query";
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import { createNameId } from "mnemonic-id";
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
import { openExternalUrl } from "@/utils/open-external-url";
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
if (!icon) {
@@ -97,12 +91,6 @@ const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.wo
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
const EMPTY_WORKSPACES = new Map();
const WORKSPACE_STATUS_DOT_WIDTH = 14;
const GITHUB_PR_STATE_LABELS: Record<PrHint["state"], string> = {
open: "Open",
merged: "Merged",
closed: "Closed",
};
interface SidebarWorkspaceListProps {
projects: SidebarProjectEntry[];
@@ -128,7 +116,6 @@ interface ProjectHeaderRowProps {
onPress: () => void;
serverId: string | null;
canCreateWorktree: boolean;
isProjectActive?: boolean;
onWorkspacePress?: () => void;
onWorktreeCreated?: (workspaceId: string) => void;
shortcutNumber?: number | null;
@@ -158,53 +145,22 @@ interface WorkspaceRowInnerProps {
onArchive?: () => void;
onCopyBranchName?: () => void;
onCopyPath?: () => void;
archiveShortcutKeys?: ShortcutKey[][] | null;
}
function WorkspacePrBadge({ hint }: { hint: PrHint }) {
const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false);
const activeColor = isHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
const handlePressIn = useCallback((event: GestureResponderEvent) => {
event.stopPropagation();
}, []);
const handlePress = useCallback(
(event: GestureResponderEvent) => {
event.stopPropagation();
void openExternalUrl(hint.url);
},
[hint.url],
);
return (
<Pressable
accessibilityRole="link"
accessibilityLabel={`${hint.state} pull request #${hint.number}`}
hitSlop={4}
onPressIn={handlePressIn}
onPress={handlePress}
onPointerEnter={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)}
style={({ pressed }) => [
styles.workspacePrBadge,
pressed && styles.workspacePrBadgePressed,
]}
>
<GitPullRequest size={12} color={activeColor} />
<Text
style={[
styles.workspacePrBadgeText,
{ color: activeColor },
]}
numberOfLines={1}
>
#{hint.number} · {GITHUB_PR_STATE_LABELS[hint.state]}
</Text>
{isHovered && <ExternalLink size={10} color={activeColor} />}
</Pressable>
);
function resolveStatusDotColor(input: {
theme: ReturnType<typeof useUnistyles>["theme"];
bucket: SidebarStateBucket;
}) {
const { theme, bucket } = input;
return bucket === "needs_input"
? theme.colors.palette.amber[500]
: bucket === "failed"
? theme.colors.palette.red[500]
: bucket === "running"
? theme.colors.palette.blue[500]
: bucket === "attention"
? theme.colors.palette.green[500]
: theme.colors.border;
}
function WorkspaceStatusIndicator({
@@ -217,48 +173,38 @@ function WorkspaceStatusIndicator({
loading?: boolean;
}) {
const { theme } = useUnistyles();
const color = resolveStatusDotColor({ theme, bucket });
const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket });
const isIdle = !loading && !shouldShowSyncedLoader && bucket === "done";
if (loading) {
if (isIdle) {
const KindIcon =
workspaceKind === "local_checkout"
? Monitor
: workspaceKind === "worktree"
? FolderGit2
: null;
if (!KindIcon) return null;
return (
<View style={styles.workspaceStatusDot}>
<ActivityIndicator size={8} color={theme.colors.foregroundMuted} />
<KindIcon size={14} color={theme.colors.foregroundMuted} />
</View>
);
}
if (shouldShowSyncedLoader) {
return (
<View style={styles.workspaceStatusDot}>
<SyncedLoader size={11} color={theme.colors.palette.amber[500]} />
</View>
);
}
const KindIcon =
workspaceKind === "local_checkout"
? Monitor
: workspaceKind === "worktree"
? FolderGit2
: null;
if (!KindIcon) return null;
const dotColor = getStatusDotColor({ theme, bucket, showDoneAsInactive: false });
return (
<View style={styles.workspaceStatusDot}>
<KindIcon size={14} color={theme.colors.foregroundMuted} />
{dotColor ? (
<View
style={[
styles.statusDotOverlay,
{
backgroundColor: dotColor,
borderColor: theme.colors.surface0,
},
]}
/>
) : null}
{loading ? (
<ActivityIndicator size={8} color={theme.colors.foregroundMuted} />
) : shouldShowSyncedLoader ? (
<SyncedLoader size={11} color={theme.colors.palette.amber[500]} />
) : bucket === "needs_input" ? (
<CircleHelp size={14} color={theme.colors.palette.amber[500]} />
) : bucket === "attention" ? (
<Check size={14} color={theme.colors.palette.green[500]} />
) : (
<View style={[styles.workspaceStatusDotFill, { backgroundColor: color }]} />
)}
</View>
);
}
@@ -278,72 +224,29 @@ function ProjectLeadingVisual({
showChevron?: boolean;
isArchiving?: boolean;
}) {
const { theme } = useUnistyles();
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(displayName);
const placeholderInitial = placeholderLabel.charAt(0).toUpperCase();
const activeWorkspace = workspace;
const shouldShowWorkspaceStatus =
activeWorkspace !== null && (isArchiving || activeWorkspace.statusBucket !== "done");
const shouldShowSyncedLoader = activeWorkspace
? shouldRenderSyncedStatusLoader({ bucket: activeWorkspace.statusBucket })
: false;
if (showChevron && chevron !== null) {
return (
<View style={styles.projectLeadingVisualSlot}>
<ProjectInlineChevron chevron={chevron} />
</View>
);
}
const projectIcon = iconDataUri ? (
<Image source={{ uri: iconDataUri }} style={styles.projectIcon} />
) : (
<View style={styles.projectIconFallback}>
<Text style={styles.projectIconFallbackText}>{placeholderInitial}</Text>
</View>
);
if (!shouldShowWorkspaceStatus || !activeWorkspace) {
return <View style={styles.projectLeadingVisualSlot}>{projectIcon}</View>;
}
if (isArchiving) {
return (
<View style={styles.projectLeadingVisualSlot}>
<ActivityIndicator size={8} color={theme.colors.foregroundMuted} />
</View>
);
}
if (shouldShowSyncedLoader) {
return (
<View style={styles.projectLeadingVisualSlot}>
<SyncedLoader size={11} color={theme.colors.palette.amber[500]} />
</View>
);
}
const dotColor = getStatusDotColor({
theme,
bucket: activeWorkspace.statusBucket,
showDoneAsInactive: false,
});
return (
<View style={styles.projectLeadingVisualSlot}>
{projectIcon}
{dotColor ? (
<View
style={[
styles.statusDotOverlay,
{
backgroundColor: dotColor,
borderColor: theme.colors.surface0,
},
]}
{showChevron && chevron !== null ? (
<ProjectInlineChevron chevron={chevron} />
) : shouldShowWorkspaceStatus && activeWorkspace ? (
<WorkspaceStatusIndicator
bucket={activeWorkspace.statusBucket}
workspaceKind={activeWorkspace.workspaceKind}
loading={isArchiving}
/>
) : null}
) : iconDataUri ? (
<Image source={{ uri: iconDataUri }} style={styles.projectIcon} />
) : (
<View style={styles.projectIconFallback}>
<Text style={styles.projectIconFallbackText}>{placeholderInitial}</Text>
</View>
)}
</View>
);
}
@@ -364,17 +267,14 @@ 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"}>
@@ -409,13 +309,8 @@ function NewWorktreeButton({
}
</Pressable>
</TooltipTrigger>
<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 side="bottom" align="end" offset={8}>
<Text style={styles.projectActionTooltipText}>New worktree</Text>
</TooltipContent>
</Tooltip>
</View>
@@ -649,7 +544,6 @@ function ProjectHeaderRow({
onPress,
serverId,
canCreateWorktree,
isProjectActive = false,
onWorkspacePress,
onWorktreeCreated,
shortcutNumber = null,
@@ -700,17 +594,6 @@ 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,
@@ -753,7 +636,6 @@ function ProjectHeaderRow({
onPress={() => createWorktreeMutation.mutate()}
visible={isHovered || isMobileBreakpoint}
loading={createWorktreeMutation.isPending}
showShortcutHint={isProjectActive}
testID={`sidebar-project-new-worktree-${project.projectKey}`}
/>
) : null}
@@ -829,16 +711,10 @@ function WorkspaceRowInner({
onArchive,
onCopyBranchName,
onCopyPath,
archiveShortcutKeys,
}: WorkspaceRowInnerProps) {
const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false);
const isMobile = Platform.OS !== "web";
const prHint = useWorkspacePrHint({
serverId: workspace.serverId,
cwd: workspace.workspaceId,
enabled: workspace.workspaceKind !== "directory",
});
const interaction = useLongPressDragInteraction({
drag,
menuController,
@@ -873,99 +749,91 @@ function WorkspaceRowInner({
onPress={handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
<View style={styles.workspaceRowMain}>
<View
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef as any}
style={styles.workspaceRowLeft}
<View
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef as any}
style={styles.workspaceRowLeft}
>
<WorkspaceStatusIndicator
bucket={workspace.statusBucket}
workspaceKind={workspace.workspaceKind}
loading={isArchiving || isCreating}
/>
<Text
style={[
styles.workspaceBranchText,
isHovered && styles.workspaceBranchTextHovered,
isCreating && styles.workspaceBranchTextCreating,
]}
numberOfLines={1}
>
<WorkspaceStatusIndicator
bucket={workspace.statusBucket}
workspaceKind={workspace.workspaceKind}
loading={isArchiving || isCreating}
/>
<Text
style={[
styles.workspaceBranchText,
isHovered && styles.workspaceBranchTextHovered,
isCreating && styles.workspaceBranchTextCreating,
]}
numberOfLines={1}
>
{workspace.name}
</Text>
</View>
<View style={styles.workspaceRowRight}>
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
{onArchive && (isHovered || isMobile) ? (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={({ hovered = false }) => [
styles.kebabButton,
hovered && styles.kebabButtonHovered,
]}
accessibilityRole="button"
accessibilityLabel="Workspace actions"
testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`}
>
{({ hovered }) => (
<MoreVertical
size={14}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260}>
{onCopyPath ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-path-${workspace.workspaceKey}`}
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
onSelect={onCopyPath}
>
Copy path
</DropdownMenuItem>
) : null}
{onCopyBranchName ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-branch-name-${workspace.workspaceKey}`}
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
onSelect={onCopyBranchName}
>
Copy branch name
</DropdownMenuItem>
) : null}
{workspace.name}
</Text>
</View>
<View style={styles.workspaceRowRight}>
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
{onArchive && (isHovered || isMobile) ? (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={({ hovered = false }) => [
styles.kebabButton,
hovered && styles.kebabButtonHovered,
]}
accessibilityRole="button"
accessibilityLabel="Workspace actions"
testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`}
>
{({ hovered }) => (
<MoreVertical
size={14}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={200}>
{onCopyPath ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-path-${workspace.workspaceKey}`}
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
onSelect={onCopyPath}
>
Copy path
</DropdownMenuItem>
) : null}
{onCopyBranchName ? (
<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}
testID={`sidebar-workspace-menu-copy-branch-name-${workspace.workspaceKey}`}
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
onSelect={onCopyBranchName}
>
{archiveLabel ?? "Archive"}
Copy branch name
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : workspace.diffStat ? (
<View style={styles.diffStatRow}>
<Text style={styles.diffStatAdditions}>+{workspace.diffStat.additions}</Text>
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text>
</View>
) : null}
{showShortcutBadge && shortcutNumber !== null ? (
<View style={styles.shortcutBadge}>
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
</View>
) : null}
</View>
) : null}
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
leading={<Archive size={14} color={theme.colors.foregroundMuted} />}
status={archiveStatus}
pendingLabel={archivePendingLabel}
onSelect={onArchive}
>
{archiveLabel ?? "Archive"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : workspace.diffStat ? (
<View style={styles.diffStatRow}>
<Text style={styles.diffStatAdditions}>+{workspace.diffStat.additions}</Text>
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text>
</View>
) : null}
{showShortcutBadge && shortcutNumber !== null ? (
<View style={styles.shortcutBadge}>
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
</View>
) : null}
</View>
{prHint ? (
<View style={styles.workspacePrBadgeRow}>
<WorkspacePrBadge hint={prHint} />
</View>
) : null}
</Pressable>
</View>
);
@@ -1035,7 +903,7 @@ function WorkspaceRowWithMenu({
void (async () => {
const confirmed = await confirmDialog({
title: "Archive worktree?",
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.`,
message: `Archive "${workspace.name}"?\n\nThis removes the worktree from the sidebar.`,
confirmLabel: "Archive",
cancelLabel: "Cancel",
destructive: true,
@@ -1123,23 +991,6 @@ 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}
@@ -1159,7 +1010,6 @@ function WorkspaceRowWithMenu({
onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
onCopyPath={handleCopyPath}
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
/>
);
}
@@ -1332,7 +1182,6 @@ function FlattenedProjectRow({
drag,
isDragging,
dragHandleProps,
isProjectActive = false,
}: {
project: SidebarProjectEntry;
displayName: string;
@@ -1347,7 +1196,6 @@ function FlattenedProjectRow({
drag: () => void;
isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps;
isProjectActive?: boolean;
}) {
if (project.projectKind === "non_git") {
return (
@@ -1378,7 +1226,6 @@ function FlattenedProjectRow({
onPress={onPress}
serverId={serverId}
canCreateWorktree={rowModel.trailingAction === "new_worktree"}
isProjectActive={isProjectActive}
onWorkspacePress={onWorkspacePress}
onWorktreeCreated={onWorktreeCreated}
shortcutNumber={shortcutNumber}
@@ -1480,13 +1327,6 @@ 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,
@@ -1579,7 +1419,6 @@ function ProjectBlock({
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
isProjectActive={isProjectActive}
/>
) : (
<>
@@ -1593,7 +1432,6 @@ function ProjectBlock({
onPress={onToggleCollapsed}
serverId={serverId}
canCreateWorktree={rowModel.trailingAction === "new_worktree"}
isProjectActive={isProjectActive}
onWorkspacePress={onWorkspacePress}
onWorktreeCreated={onWorktreeCreated}
drag={drag}
@@ -1962,7 +1800,12 @@ const styles = StyleSheet.create((theme) => ({
projectBlock: {
marginBottom: theme.spacing[1],
},
workspaceListContainer: {},
workspaceListContainer: {
marginLeft: theme.spacing[3],
paddingLeft: theme.spacing[2],
borderLeftWidth: StyleSheet.hairlineWidth,
borderLeftColor: theme.colors.surface1,
},
emptyText: {
color: theme.colors.foregroundMuted,
textAlign: "center",
@@ -1972,7 +1815,7 @@ const styles = StyleSheet.create((theme) => ({
projectRow: {
minHeight: 36,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[1],
flexDirection: "row",
@@ -2018,7 +1861,6 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.sm,
},
projectLeadingVisualSlot: {
position: "relative",
width: theme.iconSize.md,
height: theme.iconSize.md,
flexShrink: 0,
@@ -2041,7 +1883,6 @@ const styles = StyleSheet.create((theme) => ({
projectTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: "400",
minWidth: 0,
flexShrink: 1,
},
@@ -2082,37 +1923,20 @@ 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.sm,
},
projectActionTooltipShortcut: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.borderAccent,
fontSize: theme.fontSize.xs,
},
workspaceRow: {
minHeight: 36,
marginBottom: theme.spacing[1],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[3] + theme.spacing[3],
paddingRight: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
flexDirection: "column",
alignItems: "stretch",
justifyContent: "center",
gap: theme.spacing[1],
},
workspaceRowMain: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
width: "100%",
},
workspaceRowLeft: {
flexDirection: "row",
@@ -2152,22 +1976,17 @@ const styles = StyleSheet.create((theme) => ({
position: "relative",
},
workspaceStatusDot: {
position: "relative",
width: WORKSPACE_STATUS_DOT_WIDTH,
width: 14,
height: 16,
borderRadius: theme.borderRadius.full,
flexShrink: 0,
alignItems: "center",
alignItems: "flex-start",
justifyContent: "center",
},
statusDotOverlay: {
position: "absolute",
right: 0,
bottom: 0,
workspaceStatusDotFill: {
width: 7,
height: 7,
borderRadius: theme.borderRadius.full,
borderWidth: 1,
},
workspaceArchivingOverlay: {
...StyleSheet.absoluteFillObject,
@@ -2187,7 +2006,6 @@ const styles = StyleSheet.create((theme) => ({
workspaceBranchText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: "300",
lineHeight: 20,
opacity: 0.76,
flex: 1,
@@ -2199,23 +2017,6 @@ const styles = StyleSheet.create((theme) => ({
workspaceBranchTextHovered: {
opacity: 1,
},
workspacePrBadgeRow: {
paddingLeft: WORKSPACE_STATUS_DOT_WIDTH + theme.spacing[2],
},
workspacePrBadge: {
alignSelf: "flex-start",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
workspacePrBadgePressed: {
opacity: 0.82,
},
workspacePrBadgeText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
lineHeight: 14,
},
workspaceCreatingText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
@@ -2230,12 +2031,12 @@ const styles = StyleSheet.create((theme) => ({
diffStatAdditions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: "#2d8a4e",
color: theme.colors.palette.green[400],
},
diffStatDeletions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: "#c43a31",
color: theme.colors.palette.red[500],
},
kebabButton: {
padding: 2,
@@ -617,9 +617,9 @@ export default function TerminalEmulator({
height: "100%",
overflow: "hidden",
overscrollBehavior: "none",
paddingTop: 0,
paddingBottom: 0,
paddingLeft: 0,
paddingTop: 8,
paddingBottom: 8,
paddingLeft: 8,
paddingRight: 0,
}}
/>
+474 -34
View File
@@ -1,8 +1,12 @@
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";
@@ -11,6 +15,8 @@ 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,
@@ -23,10 +29,14 @@ import TerminalEmulator from "./terminal-emulator";
interface TerminalPaneProps {
serverId: string;
cwd: string;
terminalId: string;
selectedTerminalId: string | null;
onSelectedTerminalIdChange?: (terminalId: string | null) => void;
hideHeader?: boolean;
manageTerminalDirectorySubscription?: boolean;
}
const MAX_OUTPUT_CHARS = 200_000;
const TERMINAL_TAB_MAX_WIDTH = 220;
const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320];
const MODIFIER_LABELS = {
@@ -69,6 +79,8 @@ type PendingTerminalInput =
};
};
type ListTerminalsPayload = ListTerminalsResponse["payload"];
const EMPTY_MODIFIERS: ModifierState = {
ctrl: false,
shift: false,
@@ -79,10 +91,30 @@ 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,
terminalId,
selectedTerminalId,
onSelectedTerminalIdChange,
hideHeader = false,
manageTerminalDirectorySubscription = true,
}: TerminalPaneProps) {
const isScreenFocused = useIsFocused();
const { theme } = useUnistyles();
@@ -97,10 +129,12 @@ 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(
@@ -123,20 +157,20 @@ export function TerminalPane({
getOutputSessionState,
);
const selectedOutputState = useMemo(() => {
if (outputState.selectedTerminalId === terminalId) {
if (outputState.selectedTerminalId === selectedTerminalId) {
return outputState;
}
return {
...outputState,
selectedTerminalId: terminalId,
snapshotText: outputSession.readSnapshot({ terminalId }),
selectedTerminalId,
snapshotText: outputSession.readSnapshot({ terminalId: selectedTerminalId }),
snapshotSequence: 0,
chunkText: "",
chunkSequence: 0,
chunkReplay: false,
};
}, [outputSession, outputState, terminalId]);
}, [outputSession, outputState, selectedTerminalId]);
const [activeStream, setActiveStream] = useState<{
terminalId: string;
streamId: number;
@@ -146,14 +180,68 @@ export function TerminalPane({
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
const [focusRequestToken, setFocusRequestToken] = useState(0);
const [resizeRequestToken, setResizeRequestToken] = useState(0);
const terminalIdRef = useRef<string>(terminalId);
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 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(() => {
terminalIdRef.current = terminalId;
}, [terminalId]);
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]);
const requestTerminalFocus = useCallback(() => {
setFocusRequestToken((current) => current + 1);
@@ -163,19 +251,19 @@ export function TerminalPane({
}, []);
useEffect(() => {
if (isMobile || !isScreenFocused || !terminalId) {
if (isMobile || !isScreenFocused || !selectedTerminalId) {
lastAutoFocusKeyRef.current = null;
return;
}
const nextFocusKey = `${scopeKey}:${terminalId}`;
const nextFocusKey = `${scopeKey}:${selectedTerminalId}`;
if (lastAutoFocusKeyRef.current === nextFocusKey) {
return;
}
lastAutoFocusKeyRef.current = nextFocusKey;
requestTerminalFocus();
}, [isMobile, isScreenFocused, requestTerminalFocus, scopeKey, terminalId]);
}, [isMobile, isScreenFocused, requestTerminalFocus, scopeKey, selectedTerminalId]);
const clearKeyboardRefitTimeouts = useCallback(() => {
if (keyboardRefitTimeoutsRef.current.length === 0) {
@@ -214,7 +302,7 @@ export function TerminalPane({
useFocusEffect(
useCallback(() => {
if (!terminalId) {
if (!selectedTerminalId) {
return;
}
// Navigation transitions can temporarily report stale dimensions.
@@ -230,9 +318,23 @@ export function TerminalPane({
clearTimeout(handle);
}
};
}, [requestTerminalReflow, terminalId]),
}, [requestTerminalReflow, selectedTerminalId]),
);
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;
@@ -256,10 +358,110 @@ 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);
@@ -299,7 +501,7 @@ export function TerminalPane({
streamControllerRef.current = controller;
controller.setTerminal({
terminalId: isScreenFocused ? terminalIdRef.current : null,
terminalId: isScreenFocused ? selectedTerminalIdRef.current : null,
});
return () => {
@@ -319,21 +521,29 @@ export function TerminalPane({
useEffect(() => {
pendingTerminalInputRef.current = [];
const nextTerminalId = isScreenFocused ? terminalId : null;
const nextSelectedTerminalId = isScreenFocused ? selectedTerminalId : null;
outputSession.setSelectedTerminal({
terminalId: nextTerminalId,
terminalId: nextSelectedTerminalId,
});
streamControllerRef.current?.setTerminal({
terminalId: nextTerminalId,
terminalId: nextSelectedTerminalId,
});
}, [isScreenFocused, outputSession, terminalId]);
}, [isScreenFocused, outputSession, selectedTerminalId]);
const activeStreamId =
activeStream && activeStream.terminalId === terminalId ? activeStream.streamId : null;
activeStream && activeStream.terminalId === selectedTerminalId ? 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);
@@ -348,13 +558,13 @@ export function TerminalPane({
return false;
}
const currentTerminalId = terminalIdRef.current;
if (!currentTerminalId) {
const terminalId = selectedTerminalIdRef.current;
if (!terminalId) {
return false;
}
if (entry.type === "data") {
client.sendTerminalInput(currentTerminalId, {
client.sendTerminalInput(terminalId, {
type: "input",
data: entry.data,
});
@@ -365,7 +575,7 @@ export function TerminalPane({
if (encoded.length === 0) {
return true;
}
client.sendTerminalInput(currentTerminalId, {
client.sendTerminalInput(terminalId, {
type: "input",
data: encoded,
});
@@ -401,6 +611,29 @@ 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 });
}, []);
@@ -413,7 +646,7 @@ export function TerminalPane({
alt: boolean;
meta?: boolean;
}): boolean => {
if (!client || !terminalIdRef.current) {
if (!client || !selectedTerminalIdRef.current) {
enqueuePendingTerminalInput({
type: "key",
input: {
@@ -477,7 +710,7 @@ export function TerminalPane({
}
}
if (!client || !terminalIdRef.current) {
if (!client || !selectedTerminalIdRef.current) {
enqueuePendingTerminalInput({
type: "data",
data,
@@ -508,7 +741,7 @@ export function TerminalPane({
const handleTerminalResize = useCallback(
async (input: { rows: number; cols: number }) => {
const { rows, cols } = input;
if (!client || !terminalId || rows <= 0 || cols <= 0) {
if (!client || !selectedTerminalId || rows <= 0 || cols <= 0) {
return;
}
const normalizedRows = Math.floor(rows);
@@ -518,13 +751,13 @@ export function TerminalPane({
return;
}
lastReportedSizeRef.current = { rows: normalizedRows, cols: normalizedCols };
client.sendTerminalInput(terminalId, {
client.sendTerminalInput(selectedTerminalId, {
type: "resize",
rows: normalizedRows,
cols: normalizedCols,
});
},
[client, terminalId],
[client, selectedTerminalId],
);
const handleTerminalKey = useCallback(
@@ -583,10 +816,119 @@ 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}>
{isScreenFocused ? (
{selectedTerminal && isScreenFocused ? (
<View style={styles.terminalGestureContainer}>
<TerminalEmulator
dom={{
@@ -599,7 +941,7 @@ export function TerminalPane({
automaticallyAdjustContentInsets: false,
contentInsetAdjustmentBehavior: "never",
}}
streamKey={`${scopeKey}:${terminalId}`}
streamKey={`${scopeKey}:${selectedTerminal.id}`}
initialOutputText={selectedOutputState.snapshotText}
initialOutputChunkSequence={selectedOutputState.snapshotSequence}
outputChunkText={selectedOutputState.chunkText}
@@ -630,8 +972,12 @@ export function TerminalPane({
resizeRequestToken={resizeRequestToken}
/>
</View>
) : (
) : selectedTerminal ? (
<View style={styles.terminalGestureContainer} />
) : (
<View style={styles.centerState}>
<Text style={styles.stateText}>No terminal selected</Text>
</View>
)}
{isAttaching && isScreenFocused ? (
@@ -641,10 +987,10 @@ export function TerminalPane({
) : null}
</View>
{streamError ? (
{combinedError ? (
<View style={styles.errorRow}>
<Text style={styles.statusError} numberOfLines={2}>
{streamError}
{combinedError}
</Text>
</View>
) : null}
@@ -702,6 +1048,100 @@ 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,
@@ -24,7 +24,7 @@ import {
type ViewStyle,
type StyleProp,
} from "react-native";
import Animated, { Keyframe, runOnJS } from "react-native-reanimated";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Check, CheckCircle } from "lucide-react-native";
@@ -226,22 +226,6 @@ export function DropdownMenuTrigger({
);
}
function getTransformOrigin(placement: Placement, alignment: Alignment): string {
const vertical = placement === "bottom" ? "top" : placement === "top" ? "bottom" : "center";
const horizontal = alignment === "start" ? "left" : alignment === "end" ? "right" : "center";
return `${vertical} ${horizontal}`;
}
const contentEntering = new Keyframe({
0: { opacity: 0, transform: [{ scale: 0.97 }] },
100: { opacity: 1, transform: [{ scale: 1 }] },
}).duration(150);
const contentExiting = new Keyframe({
0: { opacity: 1, transform: [{ scale: 1 }] },
100: { opacity: 0, transform: [{ scale: 0.97 }] },
}).duration(100);
export function DropdownMenuContent({
children,
side = "bottom",
@@ -265,22 +249,9 @@ export function DropdownMenuContent({
testID?: string;
}>): ReactElement | null {
const { open, setOpen, triggerRef } = useDropdownMenuContext("DropdownMenuContent");
const [modalVisible, setModalVisible] = useState(false);
const [closing, setClosing] = useState(false);
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const [actualPlacement, setActualPlacement] = useState<Placement>(side);
// Keep Modal mounted during exit animation
useEffect(() => {
if (open) {
setModalVisible(true);
setClosing(false);
} else if (modalVisible) {
setClosing(true);
}
}, [open, modalVisible]);
const handleClose = useCallback(() => {
setOpen(false);
@@ -343,7 +314,6 @@ export function DropdownMenuContent({
// For fullWidth, x is simply the horizontal padding to center on screen
const x = fullWidth ? horizontalPadding : result.x;
setPosition({ x, y: result.y });
setActualPlacement(result.actualPlacement);
}, [triggerRect, contentSize, side, align, offset, fullWidth, horizontalPadding]);
const handleContentLayout = useCallback(
@@ -354,7 +324,7 @@ export function DropdownMenuContent({
[],
);
if (!modalVisible) return null;
if (!open) return null;
const { width: screenWidth } = Dimensions.get("window");
const resolvedWidthStyle: ViewStyle = fullWidth
@@ -367,7 +337,7 @@ export function DropdownMenuContent({
return (
<Modal
visible={modalVisible}
visible={open}
transparent
animationType="none"
statusBarTranslucent={Platform.OS === "android"}
@@ -381,38 +351,30 @@ export function DropdownMenuContent({
onPress={handleClose}
testID={testID ? `${testID}-backdrop` : undefined}
/>
{!closing ? (
<Animated.View
entering={contentEntering}
exiting={contentExiting.withCallback((finished) => {
"worklet";
if (finished) {
runOnJS(setModalVisible)(false);
}
})}
collapsable={false}
testID={testID}
onLayout={handleContentLayout}
style={[
styles.content,
resolvedWidthStyle,
{
position: "absolute",
top: position?.y ?? -9999,
left: position?.x ?? -9999,
transformOrigin: getTransformOrigin(actualPlacement, align),
},
]}
<Animated.View
entering={FadeIn.duration(100)}
exiting={FadeOut.duration(100)}
collapsable={false}
testID={testID}
onLayout={handleContentLayout}
style={[
styles.content,
resolvedWidthStyle,
{
position: "absolute",
top: position?.y ?? -9999,
left: position?.x ?? -9999,
},
]}
>
<ScrollView
bounces={false}
showsVerticalScrollIndicator
contentContainerStyle={{ flexGrow: 1 }}
>
<ScrollView
bounces={false}
showsVerticalScrollIndicator
contentContainerStyle={{ flexGrow: 1 }}
>
{children}
</ScrollView>
</Animated.View>
) : null}
{children}
</ScrollView>
</Animated.View>
</View>
</Modal>
);
@@ -597,9 +559,9 @@ const styles = StyleSheet.create((theme) => ({
left: 0,
},
content: {
backgroundColor: theme.colors.surface1,
backgroundColor: theme.colors.surface0,
borderWidth: 1,
borderColor: theme.colors.borderAccent,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
overflow: "hidden",
shadowColor: "#000",
@@ -642,16 +604,16 @@ const styles = StyleSheet.create((theme) => ({
borderColor: "transparent",
},
itemHovered: {
backgroundColor: theme.colors.surface2,
backgroundColor: theme.colors.surface1,
},
itemPressed: {
backgroundColor: theme.colors.surface2,
backgroundColor: theme.colors.surface1,
},
itemSelected: {
backgroundColor: theme.colors.surface2,
backgroundColor: theme.colors.surface1,
},
itemSelectedInteractive: {
backgroundColor: theme.colors.surface2,
backgroundColor: theme.colors.surface1,
},
itemSelectedAccent: {
backgroundColor: theme.colors.accent,
+4 -34
View File
@@ -6,46 +6,22 @@ import { getShortcutOs } from "@/utils/shortcut-platform";
export function Shortcut({
keys,
chord,
style,
textStyle,
}: {
keys?: ShortcutKey[];
chord?: ShortcutKey[][];
keys: 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.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 style={[styles.root, style]}>
<Text style={[styles.text, textStyle]}>{formatShortcut(keys, getShortcutOs())}</Text>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
badge: {
root: {
paddingHorizontal: theme.spacing[1],
paddingVertical: 2,
borderRadius: theme.borderRadius.md,
@@ -53,12 +29,6 @@ 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,
+46 -37
View File
@@ -49,6 +49,11 @@ import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agen
import { resolveProjectPlacement } from "@/utils/project-placement";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import type { AttachmentMetadata } from "@/attachments/types";
import {
shouldAutoReplayQueuedAgentMessage,
takeQueuedAgentMessageReplay,
type QueuedAgentReplaySource,
} from "@/contexts/session-queued-message-replay";
// Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store";
@@ -272,7 +277,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const previousAgentStatusRef = useRef<Map<string, AgentLifecycleStatus>>(new Map());
const sendAgentMessageRef = useRef<
((agentId: string, message: string, images?: AttachmentMetadata[]) => Promise<void>) | null
((
agentId: string,
message: string,
images?: AttachmentMetadata[],
messageId?: string,
) => Promise<void>) | null
>(null);
const sessionStateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const attentionNotifiedRef = useRef<Map<string, number>>(new Map());
@@ -349,7 +359,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
);
const applyAuthoritativeAgentSnapshot = useCallback(
(agent: Agent) => {
(agent: Agent, options?: { source?: QueuedAgentReplaySource }) => {
setAgents(serverId, (prev) => {
const current = prev.get(agent.id);
if (current && agent.updatedAt.getTime() < current.updatedAt.getTime()) {
@@ -423,17 +433,27 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
});
const prevStatus = previousAgentStatusRef.current.get(agent.id);
if (prevStatus === "running" && agent.status !== "running") {
if (
shouldAutoReplayQueuedAgentMessage({
previousStatus: prevStatus,
nextStatus: agent.status,
source: options?.source ?? "live",
})
) {
const session = useSessionStore.getState().sessions[serverId];
const queue = session?.queuedMessages.get(agent.id);
if (queue && queue.length > 0) {
const [next, ...rest] = queue;
const replay = takeQueuedAgentMessageReplay(session?.queuedMessages.get(agent.id));
if (replay) {
if (sendAgentMessageRef.current) {
void sendAgentMessageRef.current(agent.id, next.text, next.images);
void sendAgentMessageRef.current(
agent.id,
replay.text,
replay.images,
replay.messageId,
);
}
setQueuedMessages(serverId, (prev) => {
const updated = new Map(prev);
updated.set(agent.id, rest);
updated.set(agent.id, replay.remainingQueue);
return updated;
});
}
@@ -516,31 +536,12 @@ 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, client, scheduleAuthoritativeRevalidation, serverId],
[bumpHistorySyncGeneration, scheduleAuthoritativeRevalidation, serverId],
);
// Client activity tracking (heartbeat, push token registration)
@@ -741,7 +742,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}),
};
applyAuthoritativeAgentSnapshot(agent);
applyAuthoritativeAgentSnapshot(agent, { source: "live" });
},
[
applyAuthoritativeAgentSnapshot,
@@ -791,10 +792,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
if (payload.agent) {
const normalized = normalizeAgentSnapshot(payload.agent, serverId);
applyAuthoritativeAgentSnapshot({
...normalized,
projectPlacement: session?.agents.get(agentId)?.projectPlacement ?? null,
});
applyAuthoritativeAgentSnapshot(
{
...normalized,
projectPlacement: session?.agents.get(agentId)?.projectPlacement ?? null,
},
{ source: "hydrate" },
);
}
// Call pure reducer
@@ -1488,11 +1492,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
]);
const sendAgentMessage = useCallback(
async (agentId: string, message: string, images?: AttachmentMetadata[]) => {
const messageId = generateMessageId();
async (
agentId: string,
message: string,
images?: AttachmentMetadata[],
messageId?: string,
) => {
const resolvedMessageId = messageId ?? generateMessageId();
const userMessage: StreamItem = {
kind: "user_message",
id: messageId,
id: resolvedMessageId,
text: message,
timestamp: new Date(),
};
@@ -1526,7 +1535,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}
void client
.sendAgentMessage(agentId, message, {
messageId,
messageId: resolvedMessageId,
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
})
.catch((error) => {
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import {
shouldAutoReplayQueuedAgentMessage,
takeQueuedAgentMessageReplay,
} from "./session-queued-message-replay";
describe("takeQueuedAgentMessageReplay", () => {
it("preserves the queued message id for idempotent replay", () => {
const replay = takeQueuedAgentMessageReplay([
{
id: "msg-1",
text: "resume this",
images: [
{
id: "img-1",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "attachment:img-1",
fileName: "test.png",
byteSize: 12,
createdAt: 1,
},
],
},
{
id: "msg-2",
text: "second",
},
]);
expect(replay).toEqual({
messageId: "msg-1",
text: "resume this",
images: [
{
id: "img-1",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "attachment:img-1",
fileName: "test.png",
byteSize: 12,
createdAt: 1,
},
],
remainingQueue: [{ id: "msg-2", text: "second" }],
});
});
it("returns null when the queue is empty", () => {
expect(takeQueuedAgentMessageReplay([])).toBeNull();
expect(takeQueuedAgentMessageReplay(undefined)).toBeNull();
});
it("only auto-replays queued messages for live running-to-idle transitions", () => {
expect(
shouldAutoReplayQueuedAgentMessage({
previousStatus: "running",
nextStatus: "idle",
source: "live",
}),
).toBe(true);
expect(
shouldAutoReplayQueuedAgentMessage({
previousStatus: "running",
nextStatus: "idle",
source: "hydrate",
}),
).toBe(false);
expect(
shouldAutoReplayQueuedAgentMessage({
previousStatus: "idle",
nextStatus: "idle",
source: "live",
}),
).toBe(false);
});
});
@@ -0,0 +1,40 @@
import type { AttachmentMetadata } from "@/attachments/types";
export interface QueuedAgentMessageReplayItem {
id: string;
text: string;
images?: AttachmentMetadata[];
}
export interface QueuedAgentMessageReplay {
messageId: string;
text: string;
images?: AttachmentMetadata[];
remainingQueue: QueuedAgentMessageReplayItem[];
}
export type QueuedAgentReplaySource = "hydrate" | "live";
export function shouldAutoReplayQueuedAgentMessage(input: {
previousStatus: string | undefined;
nextStatus: string;
source: QueuedAgentReplaySource;
}): boolean {
return input.source === "live" && input.previousStatus === "running" && input.nextStatus !== "running";
}
export function takeQueuedAgentMessageReplay(
queue: readonly QueuedAgentMessageReplayItem[] | undefined,
): QueuedAgentMessageReplay | null {
if (!queue || queue.length === 0) {
return null;
}
const [next, ...remainingQueue] = queue;
return {
messageId: next.id,
text: next.text,
images: next.images,
remainingQueue,
};
}
@@ -3,7 +3,6 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
import type { CheckoutPrStatusResponse } from "@server/shared/messages";
const CHECKOUT_PR_STATUS_STALE_TIME = 20_000;
const WORKSPACE_PR_HINT_REFETCH_INTERVAL = 60_000;
function checkoutPrStatusQueryKey(serverId: string, cwd: string) {
return ["checkoutPrStatus", serverId, cwd] as const;
@@ -16,48 +15,6 @@ interface UseCheckoutPrStatusQueryOptions {
}
export type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
export interface PrHint {
url: string;
number: number;
state: "open" | "merged" | "closed";
}
function parsePullRequestNumber(url: string): number | null {
try {
const pathname = new URL(url).pathname;
const match = pathname.match(/\/pull\/(\d+)(?:\/|$)/);
if (!match) {
return null;
}
const number = Number.parseInt(match[1], 10);
return Number.isFinite(number) ? number : null;
} catch {
return null;
}
}
function selectWorkspacePrHint(payload: CheckoutPrStatusPayload): PrHint | null {
const status = payload.status;
if (!status?.url) {
return null;
}
const number = parsePullRequestNumber(status.url);
if (number === null) {
return null;
}
return {
url: status.url,
number,
state: status.isMerged || status.state === "merged"
? "merged"
: status.state === "open"
? "open"
: "closed",
};
}
export function useCheckoutPrStatusQuery({
serverId,
@@ -91,28 +48,3 @@ export function useCheckoutPrStatusQuery({
refresh: query.refetch,
};
}
export function useWorkspacePrHint({
serverId,
cwd,
enabled = true,
}: UseCheckoutPrStatusQueryOptions): PrHint | null {
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const query = useQuery<CheckoutPrStatusPayload, Error, PrHint | null>({
queryKey: checkoutPrStatusQueryKey(serverId, cwd),
queryFn: async () => {
if (!client) {
throw new Error("Daemon client not available");
}
return await client.checkoutPrStatus(cwd);
},
enabled: !!client && isConnected && !!cwd && enabled,
staleTime: CHECKOUT_PR_STATUS_STALE_TIME,
refetchInterval: WORKSPACE_PR_HINT_REFETCH_INTERVAL,
select: selectWorkspacePrHint,
});
return query.data ?? null;
}
+5 -6
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 { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
import { comboStringToShortcutKeys } 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,9 +115,8 @@ function resolveActionShortcutKeys(
const bindingId = getBindingIdForAction(actionId, platform);
if (!bindingId) return undefined;
const override = overrides[bindingId];
if (override) return chordStringToShortcutKeys(override);
const defaultKeys = getDefaultKeysForAction(actionId, platform);
return defaultKeys ? [defaultKeys] : undefined;
if (override) return comboStringToShortcutKeys(override);
return getDefaultKeysForAction(actionId, platform) ?? undefined;
}
export function useCommandCenter() {
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef } from "react";
import { useEffect, useMemo } from "react";
import { Platform } from "react-native";
import { usePathname } from "expo-router";
import { getIsDesktop } from "@/constants/layout";
@@ -17,11 +17,7 @@ import {
} from "@/keyboard/actions";
import { canToggleFileExplorerShortcut } from "@/keyboard/keyboard-shortcut-routing";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import {
type ChordState,
resolveKeyboardShortcut,
buildEffectiveBindings,
} from "@/keyboard/keyboard-shortcuts";
import { 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";
@@ -45,11 +41,6 @@ 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 ??
@@ -162,21 +153,6 @@ 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",
@@ -280,11 +256,6 @@ 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);
@@ -299,11 +270,12 @@ export function useKeyboardShortcuts({
}
}
const store = useKeyboardShortcutsStore.getState();
const focusScope = resolveKeyboardFocusScope({
target: event.target,
commandCenterOpen: store.commandCenterOpen,
});
const result = resolveKeyboardShortcut({
const match = resolveKeyboardShortcut({
event,
context: {
isMac,
@@ -316,41 +288,25 @@ export function useKeyboardShortcuts({
toggleFileExplorer,
}),
},
chordState: chordStateRef.current,
onChordReset: () => {
chordStateRef.current = {
candidateIndices: [],
step: 0,
timeoutId: null,
};
},
bindings,
});
chordStateRef.current = result.nextChordState;
if (result.preventDefault) {
event.preventDefault();
event.stopPropagation();
}
if (!result.match) {
if (!match) {
return;
}
const handled = handleAction({
action: result.match.action,
payload: result.match.payload,
action: match.action,
payload: match.payload,
event,
});
if (!handled) {
return;
}
if (result.match.preventDefault) {
if (match.preventDefault) {
event.preventDefault();
}
if (result.match.stopPropagation) {
if (match.stopPropagation) {
event.stopPropagation();
}
};
@@ -374,14 +330,6 @@ 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);
+4 -5
View File
@@ -1,12 +1,12 @@
import { useMemo } from "react";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
import { comboStringToShortcutKeys } 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,10 +18,9 @@ export function useShortcutKeys(actionId: string): ShortcutKey[][] | null {
const override = overrides[bindingId];
if (override) {
return chordStringToShortcutKeys(override);
return comboStringToShortcutKeys(override);
}
const defaultKeys = getDefaultKeysForAction(actionId, platform);
return defaultKeys ? [defaultKeys] : null;
return getDefaultKeysForAction(actionId, platform);
}, [actionId, overrides, isMac, isDesktop]);
}
-5
View File
@@ -7,8 +7,6 @@ export type KeyboardFocusScope =
export type MessageInputKeyboardActionKind =
| "focus"
| "send"
| "queue"
| "dictation-toggle"
| "dictation-cancel"
| "voice-toggle"
@@ -37,9 +35,6 @@ 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,10 +20,7 @@ export type KeyboardActionId =
| "workspace.pane.move-tab.right"
| "workspace.pane.move-tab.up"
| "workspace.pane.move-tab.down"
| "workspace.pane.close"
| "workspace.terminal.new"
| "worktree.new"
| "worktree.archive";
| "workspace.pane.close";
export type KeyboardActionDefinition =
| { id: "message-input.focus"; scope: KeyboardActionScope }
@@ -45,10 +42,7 @@ 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.terminal.new"; scope: KeyboardActionScope }
| { id: "worktree.new"; scope: KeyboardActionScope }
| { id: "worktree.archive"; scope: KeyboardActionScope };
| { id: "workspace.pane.close"; scope: KeyboardActionScope };
export type KeyboardActionHandler = {
handlerId: string;
@@ -1,11 +1,8 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import {
buildKeyboardShortcutHelpSections,
buildEffectiveBindings,
resolveKeyboardShortcut,
type ChordState,
type KeyboardShortcutContext,
type ParsedShortcutBinding,
} from "./keyboard-shortcuts";
function keyboardEvent(overrides: Partial<KeyboardEvent>): KeyboardEvent {
@@ -34,30 +31,6 @@ 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>;
@@ -66,33 +39,29 @@ function expectShortcutResolution(input: {
preventDefault?: boolean;
stopPropagation?: boolean;
}) {
const result = resolveShortcut({
event: input.event,
context: input.context,
const match = resolveKeyboardShortcut({
event: keyboardEvent(input.event),
context: shortcutContext(input.context),
});
expect(result.match?.action).toBe(input.action);
expect(match?.action).toBe(input.action);
if ("payload" in input) {
expect(result.match?.payload).toEqual(input.payload);
expect(match?.payload).toEqual(input.payload);
}
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());
expect(match?.preventDefault).toBe(input.preventDefault ?? true);
expect(match?.stopPropagation).toBe(input.stopPropagation ?? true);
}
function expectNoShortcutResolution(input: {
event: Partial<KeyboardEvent>;
context?: Partial<KeyboardShortcutContext>;
}) {
const result = resolveShortcut({
event: input.event,
context: input.context,
const match = resolveKeyboardShortcut({
event: keyboardEvent(input.event),
context: shortcutContext(input.context),
});
expect(result.match).toBeNull();
expect(result.preventDefault).toBe(false);
expect(result.nextChordState).toEqual(initialChordState());
expect(match).toBeNull();
}
type MatchingShortcutCase = {
@@ -369,66 +338,6 @@ 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", () => {
+65 -338
View File
@@ -5,7 +5,7 @@ import type {
KeyboardShortcutPayload,
MessageInputKeyboardActionKind,
} from "@/keyboard/actions";
import { type KeyCombo, parseChordString } from "@/keyboard/shortcut-string";
import { type KeyCombo, parseShortcutString } from "@/keyboard/shortcut-string";
export type { KeyCombo } from "@/keyboard/shortcut-string";
@@ -33,15 +33,8 @@ export type KeyboardShortcutHelpRow = {
note?: string;
};
export type ShortcutSectionId =
| "navigation"
| "tabs-panes"
| "projects"
| "panels"
| "agent-input";
export type KeyboardShortcutHelpSection = {
id: ShortcutSectionId;
id: "global" | "agent-input";
title: string;
rows: KeyboardShortcutHelpRow[];
};
@@ -76,7 +69,7 @@ type ShortcutPayloadDef =
interface ShortcutHelp {
id: string;
section: ShortcutSectionId;
section: "global" | "agent-input";
label: string;
keys: ShortcutKey[];
note?: string;
@@ -95,22 +88,13 @@ interface ShortcutBinding {
}
export interface ParsedShortcutBinding extends ShortcutBinding {
parsedChord: KeyCombo[];
}
export interface ChordState {
candidateIndices: number[];
step: number;
timeoutId: ReturnType<typeof setTimeout> | null;
parsedCombo: KeyCombo;
}
// --- Constants ---
const SHORTCUT_HELP_SECTION_TITLES: Record<ShortcutSectionId, string> = {
navigation: "Navigation",
"tabs-panes": "Tabs & Panes",
projects: "Projects",
panels: "Panels",
const SHORTCUT_HELP_SECTION_TITLES: Record<KeyboardShortcutHelpSection["id"], string> = {
global: "Global",
"agent-input": "Agent Input",
};
@@ -125,7 +109,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true },
help: {
id: "new-agent",
section: "projects",
section: "global",
label: "Open project",
keys: ["mod", "shift", "O"],
},
@@ -137,64 +121,12 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, terminal: false },
help: {
id: "new-agent",
section: "projects",
section: "global",
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",
@@ -203,7 +135,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, commandCenter: false },
help: {
id: "workspace-tab-new",
section: "tabs-panes",
section: "global",
label: "New agent tab",
keys: ["mod", "T"],
},
@@ -215,7 +147,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "workspace-tab-new",
section: "tabs-panes",
section: "global",
label: "New agent tab",
keys: ["mod", "T"],
},
@@ -227,7 +159,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, desktop: true, commandCenter: false },
help: {
id: "workspace-tab-close-current",
section: "tabs-panes",
section: "global",
label: "Close current tab",
keys: ["meta", "W"],
},
@@ -239,7 +171,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, desktop: true, commandCenter: false, terminal: false },
help: {
id: "workspace-tab-close-current",
section: "tabs-panes",
section: "global",
label: "Close current tab",
keys: ["ctrl", "W"],
},
@@ -251,7 +183,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { desktop: false, commandCenter: false },
help: {
id: "workspace-tab-close-current",
section: "tabs-panes",
section: "global",
label: "Close current tab",
keys: ["alt", "shift", "W"],
},
@@ -266,7 +198,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "index" },
help: {
id: "workspace-jump-index",
section: "navigation",
section: "global",
label: "Jump to workspace",
keys: ["mod", "1-9"],
},
@@ -279,7 +211,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "index" },
help: {
id: "workspace-jump-index",
section: "navigation",
section: "global",
label: "Jump to workspace",
keys: ["mod", "1-9"],
},
@@ -292,7 +224,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "index" },
help: {
id: "workspace-jump-index",
section: "navigation",
section: "global",
label: "Jump to workspace",
keys: ["alt", "1-9"],
},
@@ -307,7 +239,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "index" },
help: {
id: "workspace-tab-jump-index",
section: "navigation",
section: "global",
label: "Jump to tab",
keys: ["alt", "1-9"],
},
@@ -320,7 +252,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "index" },
help: {
id: "workspace-tab-jump-index",
section: "navigation",
section: "global",
label: "Jump to tab",
keys: ["alt", "shift", "1-9"],
},
@@ -335,7 +267,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: -1 },
help: {
id: "workspace-prev",
section: "navigation",
section: "global",
label: "Previous workspace",
keys: ["mod", "["],
},
@@ -348,7 +280,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: -1 },
help: {
id: "workspace-prev",
section: "navigation",
section: "global",
label: "Previous workspace",
keys: ["mod", "["],
},
@@ -361,7 +293,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: 1 },
help: {
id: "workspace-next",
section: "navigation",
section: "global",
label: "Next workspace",
keys: ["mod", "]"],
},
@@ -374,7 +306,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: 1 },
help: {
id: "workspace-next",
section: "navigation",
section: "global",
label: "Next workspace",
keys: ["mod", "]"],
},
@@ -387,7 +319,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: -1 },
help: {
id: "workspace-prev",
section: "navigation",
section: "global",
label: "Previous workspace",
keys: ["alt", "["],
},
@@ -400,7 +332,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: 1 },
help: {
id: "workspace-next",
section: "navigation",
section: "global",
label: "Next workspace",
keys: ["alt", "]"],
},
@@ -415,7 +347,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: -1 },
help: {
id: "workspace-tab-prev",
section: "navigation",
section: "global",
label: "Previous tab",
keys: ["alt", "shift", "["],
},
@@ -428,7 +360,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: 1 },
help: {
id: "workspace-tab-next",
section: "navigation",
section: "global",
label: "Next tab",
keys: ["alt", "shift", "]"],
},
@@ -442,7 +374,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, terminal: false, commandCenter: false },
help: {
id: "workspace-pane-split-right",
section: "tabs-panes",
section: "global",
label: "Split pane right",
keys: ["mod", "\\"],
},
@@ -454,7 +386,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, terminal: false, commandCenter: false },
help: {
id: "workspace-pane-split-down",
section: "tabs-panes",
section: "global",
label: "Split pane down",
keys: ["mod", "shift", "\\"],
},
@@ -466,7 +398,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, terminal: false, commandCenter: false },
help: {
id: "workspace-pane-focus-left",
section: "tabs-panes",
section: "global",
label: "Focus pane left",
keys: ["mod", "shift", "Left"],
},
@@ -478,7 +410,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, terminal: false, commandCenter: false },
help: {
id: "workspace-pane-focus-right",
section: "tabs-panes",
section: "global",
label: "Focus pane right",
keys: ["mod", "shift", "Right"],
},
@@ -490,7 +422,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, terminal: false, commandCenter: false },
help: {
id: "workspace-pane-focus-up",
section: "tabs-panes",
section: "global",
label: "Focus pane up",
keys: ["mod", "shift", "Up"],
},
@@ -502,7 +434,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, terminal: false, commandCenter: false },
help: {
id: "workspace-pane-focus-down",
section: "tabs-panes",
section: "global",
label: "Focus pane down",
keys: ["mod", "shift", "Down"],
},
@@ -514,7 +446,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, terminal: false, commandCenter: false },
help: {
id: "workspace-pane-move-tab-left",
section: "tabs-panes",
section: "global",
label: "Move tab left",
keys: ["mod", "shift", "alt", "Left"],
},
@@ -526,7 +458,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, terminal: false, commandCenter: false },
help: {
id: "workspace-pane-move-tab-right",
section: "tabs-panes",
section: "global",
label: "Move tab right",
keys: ["mod", "shift", "alt", "Right"],
},
@@ -538,7 +470,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, terminal: false, commandCenter: false },
help: {
id: "workspace-pane-move-tab-up",
section: "tabs-panes",
section: "global",
label: "Move tab up",
keys: ["mod", "shift", "alt", "Up"],
},
@@ -550,7 +482,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, terminal: false, commandCenter: false },
help: {
id: "workspace-pane-move-tab-down",
section: "tabs-panes",
section: "global",
label: "Move tab down",
keys: ["mod", "shift", "alt", "Down"],
},
@@ -562,38 +494,12 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, terminal: false, commandCenter: false },
help: {
id: "workspace-pane-close",
section: "tabs-panes",
section: "global",
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",
@@ -602,7 +508,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true },
help: {
id: "toggle-command-center",
section: "panels",
section: "global",
label: "Toggle command center",
keys: ["mod", "K"],
},
@@ -614,7 +520,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, terminal: false },
help: {
id: "toggle-command-center",
section: "panels",
section: "global",
label: "Toggle command center",
keys: ["mod", "K"],
},
@@ -629,7 +535,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { focusScope: "other" },
help: {
id: "show-shortcuts",
section: "panels",
section: "global",
label: "Show keyboard shortcuts",
keys: ["?"],
note: "Available when focus is not in a text field or terminal.",
@@ -644,7 +550,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true },
help: {
id: "toggle-left-sidebar",
section: "panels",
section: "global",
label: "Toggle left sidebar",
keys: ["mod", "B"],
},
@@ -662,7 +568,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "toggle-left-sidebar",
section: "panels",
section: "global",
label: "Toggle left sidebar",
keys: ["mod", "."],
},
@@ -674,7 +580,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, hasSelectedAgent: true, commandCenter: false },
help: {
id: "toggle-right-sidebar",
section: "panels",
section: "global",
label: "Toggle right sidebar",
keys: ["mod", "E"],
},
@@ -686,7 +592,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, hasSelectedAgent: true, commandCenter: false, terminal: false },
help: {
id: "toggle-right-sidebar",
section: "panels",
section: "global",
label: "Toggle right sidebar",
keys: ["mod", "E"],
},
@@ -768,52 +674,6 @@ 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",
@@ -833,12 +693,11 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
// --- Parse bindings at module load ---
function parseBinding(binding: ShortcutBinding): ParsedShortcutBinding {
const parsedChord = parseChordString(binding.combo);
const lastCombo = parsedChord.at(-1);
if (binding.repeat === false && lastCombo) {
lastCombo.repeat = false;
const parsedCombo = parseShortcutString(binding.combo);
if (binding.repeat === false) {
parsedCombo.repeat = false;
}
return { ...binding, parsedChord };
return { ...binding, parsedCombo };
}
export const DEFAULT_BINDINGS: readonly ParsedShortcutBinding[] =
@@ -852,17 +711,16 @@ export function buildEffectiveBindings(
if (override === undefined) {
return binding;
}
let parsedChord: KeyCombo[];
let parsedCombo: KeyCombo;
try {
parsedChord = parseChordString(override);
parsedCombo = parseShortcutString(override);
} catch {
return binding;
}
const lastCombo = parsedChord.at(-1);
if (binding.repeat === false && lastCombo) {
lastCombo.repeat = false;
if (binding.repeat === false) {
parsedCombo.repeat = false;
}
return { ...binding, combo: override, parsedChord };
return { ...binding, combo: override, parsedCombo };
});
}
@@ -939,27 +797,6 @@ 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,
@@ -974,131 +811,24 @@ function helpMatchesPlatform(
export function resolveKeyboardShortcut(input: {
event: KeyboardEvent;
context: KeyboardShortcutContext;
chordState: ChordState;
onChordReset: () => void;
bindings?: readonly ParsedShortcutBinding[];
}): {
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)) {
}): KeyboardShortcutMatch | null {
const { event, context, bindings = DEFAULT_BINDINGS } = input;
for (const binding of bindings) {
if (!matchesCombo(binding.parsedCombo, 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 {
match: completedMatch,
nextChordState: resetChordState(chordState),
preventDefault: false,
action: binding.action,
payload: resolvePayload(binding.payload, event),
preventDefault: binding.preventDefault ?? true,
stopPropagation: binding.stopPropagation ?? true,
};
}
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,
};
return null;
}
export function getBindingIdForAction(
@@ -1138,11 +868,8 @@ export function buildKeyboardShortcutHelpSections(
bindings: readonly ParsedShortcutBinding[] = DEFAULT_BINDINGS,
): KeyboardShortcutHelpSection[] {
const seenRows = new Set<string>();
const rowsBySection = new Map<ShortcutSectionId, KeyboardShortcutHelpRow[]>([
["navigation", []],
["tabs-panes", []],
["projects", []],
["panels", []],
const rowsBySection = new Map<KeyboardShortcutHelpSection["id"], KeyboardShortcutHelpRow[]>([
["global", []],
["agent-input", []],
]);
@@ -1172,7 +899,7 @@ export function buildKeyboardShortcutHelpSections(
});
}
const sectionOrder: ShortcutSectionId[] = ["navigation", "tabs-panes", "projects", "panels", "agent-input"];
const sectionOrder: KeyboardShortcutHelpSection["id"][] = ["global", "agent-input"];
return sectionOrder.flatMap((sectionId) => {
const rows = rowsBySection.get(sectionId) ?? [];
@@ -36,8 +36,6 @@ 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" };
@@ -103,10 +101,6 @@ 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[] = [];
@@ -126,10 +120,6 @@ 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",
@@ -166,10 +156,6 @@ 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;
+10 -2
View File
@@ -49,7 +49,7 @@ function useTerminalPanelDescriptor(
function TerminalPanel() {
const isFocused = useIsFocused();
const { serverId, workspaceId, target } = usePaneContext();
const { serverId, workspaceId, target, openTab } = usePaneContext();
invariant(target.kind === "terminal", "TerminalPanel requires terminal target");
if (!isFocused) {
@@ -60,7 +60,15 @@ function TerminalPanel() {
<TerminalPane
serverId={serverId}
cwd={workspaceId}
terminalId={target.terminalId}
selectedTerminalId={target.terminalId}
onSelectedTerminalIdChange={(terminalId) => {
if (!terminalId) {
return;
}
openTab({ kind: "terminal", terminalId });
}}
hideHeader
manageTerminalDirectorySubscription={false}
/>
);
}
+3 -3
View File
@@ -67,7 +67,7 @@ import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
type SettingsSectionId =
| "hosts"
| "appearance"
| "shortcuts"
| "keyboard-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: "shortcuts", label: "Shortcuts", icon: Keyboard },
{ id: "keyboard-shortcuts", label: "Keyboard 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 "shortcuts":
case "keyboard-shortcuts":
return <KeyboardShortcutsSection />;
case "diagnostics":
return <DiagnosticsSection {...diagnosticsProps} />;
@@ -10,31 +10,16 @@ import {
getBindingIdForAction,
type KeyboardShortcutHelpRow,
} from "@/keyboard/keyboard-shortcuts";
import {
chordStringToShortcutKeys,
comboStringToShortcutKeys,
keyboardEventToComboString,
} from "@/keyboard/shortcut-string";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { comboStringToShortcutKeys, keyboardEventToComboString } from "@/keyboard/shortcut-string";
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,
}: {
@@ -42,38 +27,29 @@ function ShortcutRow({
bindingId: string | null;
overrideCombo: string | undefined;
isCapturing: boolean;
capturedCombos: string[];
onRebind: () => void;
onDone: () => void;
onCancel: () => void;
onReset: () => void;
}) {
const displayChord = overrideCombo ? chordStringToShortcutKeys(overrideCombo) : [row.keys];
const displayKeys = overrideCombo ? comboStringToShortcutKeys(overrideCombo) : row.keys;
return (
<View style={[styles.row, isCapturing && styles.rowCapturing]}>
<Text style={styles.rowLabel}>{row.label}</Text>
<View style={styles.rowActions}>
{isCapturing ? (
<ShortcutSequence chord={capturedCombos} />
<Text style={styles.capturingText}>Press shortcut...</Text>
) : (
<Shortcut chord={displayChord} />
<Shortcut keys={displayKeys} />
)}
{bindingId !== null && (
<>
{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>
</>
<Button
variant="ghost"
size="sm"
onPress={isCapturing ? onCancel : onRebind}
>
{isCapturing ? "Cancel" : "Rebind"}
</Button>
)}
{overrideCombo !== undefined && !isCapturing && (
<Button variant="ghost" size="sm" onPress={onReset}>
@@ -87,73 +63,51 @@ 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;
function handleKeyDown(event: KeyboardEvent) {
event.preventDefault();
event.stopPropagation();
const activeBindingId = capturingBindingId;
function handleKeyDown(event: KeyboardEvent) {
const key = event.key ?? "";
if (key === "Backspace") {
setCapturedCombos((current) => (current.length > 0 ? current.slice(0, -1) : current));
if (key === "Escape") {
event.preventDefault();
event.stopPropagation();
setCapturingBindingId(null);
return;
}
if (key === "Alt" || key === "Control" || key === "Meta" || key === "Shift") {
return;
}
const comboString = keyboardEventToComboString(event);
if (comboString === null) {
return;
}
if (comboString === null) return;
setCapturedCombos((current) => [...current, comboString]);
event.preventDefault();
event.stopPropagation();
void setOverride(activeBindingId, comboString);
setCapturingBindingId(null);
}
window.addEventListener("keydown", handleKeyDown, true);
return () => {
window.removeEventListener("keydown", handleKeyDown, true);
};
}, [capturingBindingId]);
useEffect(() => {
return () => {
setCapturingShortcut(false);
};
}, [setCapturingShortcut]);
}, [capturingBindingId, setOverride]);
if (Platform.OS !== "web") {
return (
<View style={settingsStyles.section}>
<Text style={settingsStyles.sectionTitle}>Shortcuts</Text>
<Text style={settingsStyles.sectionTitle}>Keyboard Shortcuts</Text>
<View style={[settingsStyles.card, styles.mobileCard]}>
<Text style={styles.mobileText}>
Keyboard shortcuts are only available on desktop.
@@ -166,7 +120,7 @@ export function KeyboardShortcutsSection() {
return (
<View style={settingsStyles.section}>
<View style={styles.sectionHeader}>
<Text style={settingsStyles.sectionTitle}>Shortcuts</Text>
<Text style={settingsStyles.sectionTitle}>Keyboard Shortcuts</Text>
{hasOverrides && (
<Button variant="ghost" size="sm" onPress={() => void resetAll()}>
Reset all
@@ -190,14 +144,8 @@ export function KeyboardShortcutsSection() {
bindingId={bindingId}
overrideCombo={overrideCombo}
isCapturing={capturingBindingId === bindingId}
capturedCombos={capturingBindingId === bindingId ? capturedCombos : []}
onRebind={() => {
if (bindingId) {
startCapture(bindingId);
}
}}
onDone={saveCapture}
onCancel={cancelCapture}
onRebind={() => setCapturingBindingId(bindingId)}
onCancel={() => setCapturingBindingId(null)}
onReset={() => {
if (bindingId) void removeOverride(bindingId);
}}
@@ -20,7 +20,6 @@ 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 {
@@ -309,10 +308,6 @@ 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);
@@ -453,12 +448,10 @@ export function WorkspaceDesktopTabsRow({
>
<SquarePen size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<TooltipContent side="bottom" align="end" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>New agent tab</Text>
{newAgentTabKeys ? (
<Shortcut chord={newAgentTabKeys} style={styles.newTabTooltipShortcut} />
) : null}
<Shortcut keys={["mod", "T"]} style={styles.newTabTooltipShortcut} />
</View>
</TooltipContent>
</Tooltip>
@@ -474,13 +467,8 @@ export function WorkspaceDesktopTabsRow({
>
<SquareTerminal size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger>
<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 side="bottom" align="end" offset={8}>
<Text style={styles.newTabTooltipText}>New terminal tab</Text>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
@@ -495,13 +483,8 @@ export function WorkspaceDesktopTabsRow({
>
<Columns2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger>
<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 side="bottom" align="end" offset={8}>
<Text style={styles.newTabTooltipText}>Split pane right</Text>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
@@ -516,13 +499,8 @@ export function WorkspaceDesktopTabsRow({
>
<Rows2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger>
<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 side="bottom" align="end" offset={8}>
<Text style={styles.newTabTooltipText}>Split pane down</Text>
</TooltipContent>
</Tooltip>
</View>
@@ -1465,9 +1465,6 @@ 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);
@@ -1496,7 +1493,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
return false;
}
},
[activeTabId, handleCloseTabById, handleCreateDraftTab, handleCreateTerminal, navigateToTabId, tabs],
[activeTabId, handleCloseTabById, handleCreateDraftTab, navigateToTabId, tabs],
);
const handleWorkspacePaneAction = useCallback(
@@ -1601,7 +1598,6 @@ 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,9 +4,7 @@ import { useKeyboardShortcutsStore } from "./keyboard-shortcuts-store";
beforeEach(() => {
useKeyboardShortcutsStore.setState({
commandCenterOpen: false,
projectPickerOpen: false,
shortcutsDialogOpen: false,
capturingShortcut: false,
altDown: false,
cmdOrCtrlDown: false,
sidebarShortcutWorkspaceTargets: [],
@@ -20,10 +18,4 @@ 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,7 +5,6 @@ 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. */
@@ -16,7 +15,6 @@ 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;
@@ -28,7 +26,6 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>((set) =>
commandCenterOpen: false,
projectPickerOpen: false,
shortcutsDialogOpen: false,
capturingShortcut: false,
altDown: false,
cmdOrCtrlDown: false,
sidebarShortcutWorkspaceTargets: [],
@@ -37,7 +34,6 @@ 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,20 +2,8 @@ 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;
}
@@ -13,7 +13,7 @@ export interface SidebarProjectWorkspaceLinkRowModel {
export interface SidebarProjectSectionRowModel {
kind: "project_section";
chevron: "expand" | "collapse" | null;
chevron: "expand" | "collapse";
trailingAction: "new_worktree" | "none";
}
@@ -22,7 +22,7 @@ export type SidebarProjectRowModel =
| SidebarProjectSectionRowModel;
export function isSidebarProjectFlattened(project: SidebarProjectEntry): boolean {
return project.workspaces.length === 1 && project.projectKind !== "git";
return project.workspaces.length === 1;
}
export function buildSidebarProjectRowModel(input: {
@@ -50,12 +50,9 @@ export function buildSidebarProjectRowModel(input: {
};
}
const collapsible =
input.project.projectKind === "git" || input.project.workspaces.length > 1;
return {
kind: "project_section",
chevron: collapsible ? (input.collapsed ? "expand" : "collapse") : null,
chevron: input.collapsed ? "expand" : "collapse",
trailingAction: input.project.projectKind === "git" ? "new_worktree" : "none",
};
}
+4 -18
View File
@@ -1,23 +1,7 @@
#!/bin/sh
set -eu
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)
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
RESOURCES_DIR=$(CDPATH= cd -- "${SCRIPT_DIR}/.." && pwd)
# macOS: MacOS/Paseo is a sibling of Resources/
@@ -31,4 +15,6 @@ else
exit 1
fi
exec env PASEO_DESKTOP_CLI=1 "${APP_EXECUTABLE}" "$@"
CLI_ENTRY="${RESOURCES_DIR}/app.asar/node_modules/@getpaseo/cli/dist/index.js"
exec env ELECTRON_RUN_AS_NODE=1 "${APP_EXECUTABLE}" "${CLI_ENTRY}" "$@"
+4 -2
View File
@@ -4,10 +4,12 @@ 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 "PASEO_DESKTOP_CLI=1"
"%APP_EXECUTABLE%" %*
set "ELECTRON_RUN_AS_NODE=1"
"%APP_EXECUTABLE%" "%CLI_ENTRY%" %*
-1
View File
@@ -8,7 +8,6 @@
"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": {
+8 -14
View File
@@ -16,7 +16,7 @@ import {
sendLocalTransportMessage,
closeLocalTransportSession,
} from "./local-transport.js";
import { createNodeEntrypointInvocation, resolveDaemonRunnerEntrypoint } from "./runtime-paths.js";
import { createElectronNodeEnv, resolveDaemonRunnerEntrypoint } from "./runtime-paths.js";
const DAEMON_LOG_FILENAME = "daemon.log";
const DAEMON_PID_FILENAME = "paseo.pid";
@@ -293,23 +293,17 @@ 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(
invocation.command,
invocation.args,
process.execPath,
[...daemonRunner.execArgv, daemonRunner.entryPath],
{
detached: true,
env: invocation.env,
env: createElectronNodeEnv({
...process.env,
PASEO_HOME: home,
...(corsOrigins ? { PASEO_CORS_ORIGINS: corsOrigins } : {}),
}),
stdio: ["ignore", "ignore", "ignore"],
},
);
@@ -1,151 +0,0 @@
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("ignores --no-sandbox injected by Linux wrapper", () => {
expect(
parseCliPassthroughArgsFromArgv({
argv: ["/usr/bin/Paseo", "--no-sandbox", "status"],
isDefaultApp: false,
forceCli: false,
}),
).toEqual(["status"]);
});
it("returns null when only --no-sandbox is present", () => {
expect(
parseCliPassthroughArgsFromArgv({
argv: ["/usr/bin/Paseo", "--no-sandbox"],
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",
},
});
});
});
});
@@ -1,78 +0,0 @@
const IGNORED_ARG_PREFIXES = ["-psn_", "--no-sandbox"];
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) => !IGNORED_ARG_PREFIXES.some((p) => arg.startsWith(p)));
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,
};
}
@@ -1,21 +0,0 @@
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);
});
+23 -45
View File
@@ -3,23 +3,21 @@ 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: {
@@ -75,10 +73,6 @@ 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}`);
@@ -88,11 +82,16 @@ function assertPathExists(input: { label: string; filePath: string }): string {
}
export function parseCliPassthroughArgsFromArgv(argv: string[]): string[] | null {
return parseCliPassthroughArgs({
argv,
isDefaultApp: process.defaultApp,
forceCli: process.env[DESKTOP_CLI_ENV] === "1",
});
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;
}
export function resolveDaemonRunnerEntrypoint(): NodeEntrypointSpec {
@@ -168,39 +167,18 @@ export function resolveCliEntrypoint(): NodeEntrypointSpec {
};
}
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,
});
export function createElectronNodeEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return {
...baseEnv,
ELECTRON_RUN_AS_NODE: "1",
};
}
function spawnCliProcess(args: string[]): SpawnSyncReturns<Buffer> {
const cli = resolveCliEntrypoint();
const invocation = createNodeEntrypointInvocation({
entrypoint: cli,
argvMode: "bare",
args,
baseEnv: process.env,
});
return spawnSync(invocation.command, invocation.args, {
env: invocation.env,
return spawnSync(process.execPath, [...cli.execArgv, cli.entryPath, ...args], {
env: createElectronNodeEnv(process.env),
stdio: "inherit",
});
}
+1 -2
View File
@@ -5,9 +5,8 @@
"main": "build/index.js",
"types": "build/index.d.ts",
"scripts": {
"build": "tsc",
"build": "expo-module build",
"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,
["--inspect", "--heapsnapshot-near-heap-limit=3", ...process.execArgv, daemonRunnerEntry, "--dev", ...process.argv.slice(2)],
[...process.execArgv, daemonRunnerEntry, "--dev", ...process.argv.slice(2)],
{
stdio: "inherit",
env: process.env,
@@ -1588,6 +1588,34 @@ describe("AgentManager", () => {
}
});
test("notifyAgentState does not mutate updatedAt", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000121",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
});
const before = manager.getAgent(snapshot.id);
expect(before).toBeDefined();
manager.notifyAgentState(snapshot.id);
const after = manager.getAgent(snapshot.id);
expect(after).toBeDefined();
expect(after!.updatedAt.getTime()).toBe(before!.updatedAt.getTime());
});
test("recordUserMessage can skip emitting agent_state when run start will emit running", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
@@ -247,6 +247,7 @@ type LiveEventStreamingSession = AgentSession & {
const DEFAULT_TIMELINE_FETCH_LIMIT = 200;
const LIVE_BACKLOG_TERMINAL_REPLAY_DELAY_MS = 300;
const RECENT_USER_MESSAGE_REPLAY_WINDOW_MS = 15_000;
const BUSY_STATUSES: AgentLifecycleStatus[] = ["initializing", "running"];
const AgentIdSchema = z.string().uuid();
@@ -790,7 +791,6 @@ export class AgentManager {
if (agent.runtimeInfo) {
agent.runtimeInfo = { ...agent.runtimeInfo, modeId };
}
this.touchUpdatedAt(agent);
this.emitState(agent);
}
@@ -807,7 +807,6 @@ export class AgentManager {
if (agent.runtimeInfo) {
agent.runtimeInfo = { ...agent.runtimeInfo, model: normalizedModelId };
}
this.touchUpdatedAt(agent);
this.emitState(agent);
}
@@ -823,7 +822,6 @@ export class AgentManager {
}
agent.config.thinkingOptionId = normalizedThinkingOptionId ?? undefined;
this.touchUpdatedAt(agent);
this.emitState(agent);
}
@@ -842,7 +840,6 @@ export class AgentManager {
const agent = this.requireAgent(agentId);
agent.labels = { ...agent.labels, ...labels };
await this.persistSnapshot(agent);
this.touchUpdatedAt(agent);
this.emitState(agent);
}
@@ -851,7 +848,6 @@ export class AgentManager {
if (!agent || agent.internal) {
return;
}
this.touchUpdatedAt(agent);
this.emitState(agent);
}
@@ -935,6 +931,66 @@ export class AgentManager {
}
}
classifyRecordedUserMessage(
agentId: string,
text: string,
options?: { messageId?: string },
): "new" | "duplicate" | "conflict" {
const normalizedMessageId = normalizeMessageId(options?.messageId);
if (!normalizedMessageId) {
return "new";
}
const agent = this.requireAgent(agentId);
for (const row of agent.timelineRows) {
if (row.item.type !== "user_message") {
continue;
}
const rowMessageId = normalizeMessageId(row.item.messageId);
if (rowMessageId !== normalizedMessageId) {
continue;
}
return row.item.text === text ? "duplicate" : "conflict";
}
if (this.isRecentUserMessageReplay(agent, text)) {
return "duplicate";
}
return "new";
}
private isRecentUserMessageReplay(agent: ManagedAgent, text: string): boolean {
let latestUserMessageRow: AgentTimelineRow | null = null;
for (let index = agent.timelineRows.length - 1; index >= 0; index -= 1) {
const row = agent.timelineRows[index];
if (!row || row.item.type !== "user_message") {
continue;
}
latestUserMessageRow = row;
break;
}
const latestUserMessageItem =
latestUserMessageRow?.item.type === "user_message" ? latestUserMessageRow.item : null;
if (!latestUserMessageRow || !latestUserMessageItem || latestUserMessageItem.text !== text) {
return false;
}
if (agent.lifecycle === "running" || agent.pendingRun || agent.pendingReplacement) {
return true;
}
const rowTimestampMs = Date.parse(latestUserMessageRow.timestamp);
const latestActivityMs = Math.max(
Number.isFinite(rowTimestampMs) ? rowTimestampMs : 0,
agent.updatedAt.getTime(),
agent.lastUserMessageAt?.getTime() ?? 0,
);
return Date.now() - latestActivityMs <= RECENT_USER_MESSAGE_REPLAY_WINDOW_MS;
}
async appendTimelineItem(agentId: string, item: AgentTimelineItem): Promise<void> {
const agent = this.requireAgent(agentId);
this.touchUpdatedAt(agent);
@@ -1058,7 +1114,6 @@ export class AgentManager {
"streamAgent.finalize: applying terminal state",
);
if (!shouldHoldBusyForReplacement) {
this.touchUpdatedAt(mutableAgent);
this.emitState(mutableAgent);
this.flushLiveEventBacklog(mutableAgent);
}
@@ -1155,7 +1210,6 @@ export class AgentManager {
const lifecycle = (latestActive as { lifecycle: AgentLifecycleStatus }).lifecycle;
if (!hasForegroundRun && lifecycle === "running") {
latestActive.lifecycle = "idle";
self.touchUpdatedAt(latestActive);
self.emitState(latestActive);
self.flushLiveEventBacklog(latestActive);
}
@@ -1313,7 +1367,6 @@ export class AgentManager {
});
}
agent.pendingPermissions.clear();
this.touchUpdatedAt(agent);
this.emitState(agent);
}
@@ -1,5 +1,6 @@
import { describe, expect, test } from "vitest";
import { existsSync, rmSync } from "node:fs";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
import {
__codexAppServerInternals,
@@ -97,4 +98,47 @@ describe("Codex app-server provider", () => {
expect(item.detail.newString).toBeUndefined();
}
});
test("does not buffer terminal Codex events before turn_started", () => {
const terminalEvents: AgentStreamEvent[] = [
{ type: "turn_completed", provider: "codex", usage: undefined },
{ type: "turn_failed", provider: "codex", error: "boom" },
{ type: "turn_canceled", provider: "codex", reason: "interrupted" },
];
for (const event of terminalEvents) {
expect(__codexAppServerInternals.shouldBufferCodexEventUntilTurnStarted(event)).toBe(false);
}
expect(
__codexAppServerInternals.shouldBufferCodexEventUntilTurnStarted({
type: "timeline",
provider: "codex",
item: { type: "assistant_message", text: "stale chunk" },
}),
).toBe(true);
});
test("only synthesizes completion from idle thread status while a run is active", () => {
expect(
__codexAppServerInternals.shouldSynthesizeTurnCompletionFromThreadStatus({
statusType: "idle",
hasActiveRun: true,
}),
).toBe(true);
expect(
__codexAppServerInternals.shouldSynthesizeTurnCompletionFromThreadStatus({
statusType: "idle",
hasActiveRun: false,
}),
).toBe(false);
expect(
__codexAppServerInternals.shouldSynthesizeTurnCompletionFromThreadStatus({
statusType: "running",
hasActiveRun: true,
}),
).toBe(false);
});
});
@@ -1195,6 +1195,18 @@ const ThreadStartedNotificationSchema = z
})
.passthrough();
const ThreadStatusChangedNotificationSchema = z
.object({
threadId: z.string().optional(),
status: z
.object({
type: z.string().optional(),
})
.passthrough()
.optional(),
})
.passthrough();
const TurnStartedNotificationSchema = z
.object({
turn: z.object({ id: z.string() }).passthrough(),
@@ -1402,6 +1414,16 @@ const ItemFileChangeOutputDeltaNotificationSchema = z
})
.passthrough();
const ItemCommandExecutionOutputDeltaNotificationSchema = z
.object({
itemId: z.string().optional(),
callId: z.string().optional(),
stream: z.string().optional(),
delta: z.string().optional(),
chunk: z.string().optional(),
})
.passthrough();
const CodexEventTurnDiffNotificationSchema = z
.object({
msg: z
@@ -1416,6 +1438,7 @@ const CodexEventTurnDiffNotificationSchema = z
type ParsedCodexNotification =
| { kind: "thread_started"; threadId: string }
| { kind: "thread_status_changed"; threadId: string | null; statusType: string | null }
| { kind: "turn_started"; turnId: string }
| { kind: "turn_completed"; status: string; errorMessage: string | null }
| { kind: "plan_updated"; plan: Array<{ step: string | null; status: string | null }> }
@@ -1499,6 +1522,25 @@ const CodexNotificationSchema = z.union([
params,
}),
),
z
.object({
method: z.literal("thread/status/changed"),
params: ThreadStatusChangedNotificationSchema,
})
.transform(
({ params }): ParsedCodexNotification => ({
kind: "thread_status_changed",
threadId: params.threadId ?? null,
statusType: params.status?.type ?? null,
}),
),
z.object({ method: z.literal("thread/status/changed"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({
kind: "invalid_payload",
method,
params,
}),
),
z
.object({ method: z.literal("turn/started"), params: TurnStartedNotificationSchema })
.transform(
@@ -1872,6 +1914,28 @@ const CodexNotificationSchema = z.union([
params,
}),
),
z
.object({
method: z.literal("item/commandExecution/outputDelta"),
params: ItemCommandExecutionOutputDeltaNotificationSchema,
})
.transform(
({ params }): ParsedCodexNotification => ({
kind: "exec_command_output_delta",
callId: params.callId ?? params.itemId ?? null,
stream: params.stream ?? null,
chunk: params.delta ?? params.chunk ?? null,
}),
),
z
.object({ method: z.literal("item/commandExecution/outputDelta"), params: z.unknown() })
.transform(
({ method, params }): ParsedCodexNotification => ({
kind: "invalid_payload",
method,
params,
}),
),
z
.object({
method: z.literal("codex/event/turn_diff"),
@@ -2034,8 +2098,37 @@ export async function codexAppServerTurnInputFromPrompt(
export const __codexAppServerInternals = {
mapCodexPatchNotificationToToolCall,
shouldBufferCodexEventUntilTurnStarted,
shouldSynthesizeTurnCompletionFromThreadStatus,
};
function isTerminalAgentStreamEvent(event: AgentStreamEvent): boolean {
return (
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled"
);
}
function shouldBufferCodexEventUntilTurnStarted(event: AgentStreamEvent): boolean {
if (
event.type === "permission_requested" ||
event.type === "permission_resolved" ||
event.type === "turn_started" ||
isTerminalAgentStreamEvent(event)
) {
return false;
}
return true;
}
function shouldSynthesizeTurnCompletionFromThreadStatus(input: {
statusType: string | null;
hasActiveRun: boolean;
}): boolean {
return input.hasActiveRun && input.statusType === "idle";
}
class CodexAppServerAgentSession implements AgentSession {
readonly provider = CODEX_PROVIDER;
readonly capabilities = CODEX_APP_SERVER_CAPABILITIES;
@@ -2074,6 +2167,7 @@ class CodexAppServerAgentSession implements AgentSession {
private warnedInvalidNotificationPayloads = new Set<string>();
private warnedIncompleteEditToolCallIds = new Set<string>();
private latestUsage: AgentUsage | undefined;
private runAwaitingTerminal = false;
private connected = false;
private collaborationModes: Array<{
name: string;
@@ -2500,33 +2594,29 @@ class CodexAppServerAgentSession implements AgentSession {
}
await this.client.request("turn/start", params, TURN_START_TIMEOUT_MS);
this.runAwaitingTerminal = true;
let sawTurnStarted = false;
for await (const event of queue) {
// Drop pre-start timeline noise that can leak from the previous turn.
// Keep permission events, which can legitimately arrive before turn_started.
// Keep permission and terminal events, which can legitimately arrive
// before turn_started on resumed/replayed requests.
if (!sawTurnStarted) {
if (event.type === "permission_requested" || event.type === "permission_resolved") {
yield event;
if (shouldBufferCodexEventUntilTurnStarted(event)) {
continue;
}
if (event.type === "turn_started") {
sawTurnStarted = true;
} else {
continue;
}
}
yield event;
if (
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled"
) {
if (isTerminalAgentStreamEvent(event)) {
break;
}
}
} finally {
this.runAwaitingTerminal = false;
if (this.eventQueue === queue) {
this.eventQueue = null;
}
@@ -2860,6 +2950,31 @@ class CodexAppServerAgentSession implements AgentSession {
return;
}
if (parsed.kind === "thread_status_changed") {
if (
shouldSynthesizeTurnCompletionFromThreadStatus({
statusType: parsed.statusType,
hasActiveRun: this.runAwaitingTerminal,
})
) {
this.runAwaitingTerminal = false;
this.currentTurnId = null;
this.emittedItemStartedIds.clear();
this.emittedItemCompletedIds.clear();
this.emittedExecCommandStartedCallIds.clear();
this.emittedExecCommandCompletedCallIds.clear();
this.pendingCommandOutputDeltas.clear();
this.pendingFileChangeOutputDeltas.clear();
this.warnedIncompleteEditToolCallIds.clear();
this.emitEvent({
type: "turn_completed",
provider: CODEX_PROVIDER,
usage: this.latestUsage,
});
}
return;
}
if (parsed.kind === "turn_started") {
this.currentTurnId = parsed.turnId;
this.emittedItemStartedIds.clear();
@@ -2874,6 +2989,8 @@ class CodexAppServerAgentSession implements AgentSession {
}
if (parsed.kind === "turn_completed") {
this.runAwaitingTerminal = false;
this.currentTurnId = null;
if (parsed.status === "failed") {
this.emitEvent({
type: "turn_failed",
@@ -1,174 +0,0 @@
import { describe, test, expect, beforeAll, afterAll } from "vitest";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import type { SessionOutboundMessage } from "../messages.js";
type AgentUpdateMessage = Extract<SessionOutboundMessage, { type: "agent_update" }>;
type AgentUpdatePayload = AgentUpdateMessage["payload"];
type AgentUpsertPayload = Extract<AgentUpdatePayload, { kind: "upsert" }>;
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
function collectAgentUpdates(client: DaemonClient): {
updates: AgentUpdatePayload[];
unsub: () => void;
} {
const updates: AgentUpdatePayload[] = [];
const unsub = client.on("agent_update", (message) => {
if (message.type === "agent_update") {
updates.push(message.payload);
}
});
return { updates, unsub };
}
function lastUpsertFor(updates: AgentUpdatePayload[], agentId: string): AgentUpsertPayload | undefined {
return updates
.filter((u): u is AgentUpsertPayload => u.kind === "upsert" && u.agent.id === agentId)
.at(-1);
}
describe("mode-switch update propagation", () => {
let ctx: DaemonTestContext;
beforeAll(async () => {
ctx = await createDaemonTestContext();
}, 30000);
afterAll(async () => {
if (ctx) await ctx.cleanup();
}, 30000);
test("agent_update event arrives with updated currentModeId after setAgentMode", async () => {
const { updates, unsub } = collectAgentUpdates(ctx.client);
const agent = await ctx.client.createAgent({
provider: "claude",
cwd: "/tmp",
modeId: "default",
});
expect(agent.currentModeId).toBe("default");
await sleep(300);
const beforeCount = updates.length;
await ctx.client.setAgentMode(agent.id, "bypassPermissions");
await sleep(500);
const modeUpdate = updates
.slice(beforeCount)
.find(
(u): u is AgentUpsertPayload =>
u.kind === "upsert" &&
u.agent.id === agent.id &&
u.agent.currentModeId === "bypassPermissions",
);
expect(modeUpdate).toBeDefined();
unsub();
});
test("updatedAt is bumped when mode changes", async () => {
const { updates, unsub } = collectAgentUpdates(ctx.client);
const agent = await ctx.client.createAgent({
provider: "claude",
cwd: "/tmp",
modeId: "default",
});
await sleep(300);
const updatedAtBefore = lastUpsertFor(updates, agent.id)?.agent.updatedAt;
await ctx.client.setAgentMode(agent.id, "bypassPermissions");
await sleep(500);
const updatedAtAfter = lastUpsertFor(updates, agent.id)?.agent.updatedAt;
expect(updatedAtBefore).toBeTruthy();
expect(updatedAtAfter).toBeTruthy();
expect(updatedAtAfter).not.toBe(updatedAtBefore);
unsub();
});
test("mode switch is visible in second client bootstrap snapshot", async () => {
const agent = await ctx.client.createAgent({
provider: "claude",
cwd: "/tmp",
modeId: "default",
});
await sleep(200);
await ctx.client.setAgentMode(agent.id, "plan");
await sleep(200);
const client2 = new DaemonClient({
url: `ws://127.0.0.1:${ctx.daemon.port}/ws`,
});
await client2.connect();
const bootstrapResult = await client2.fetchAgents({
subscribe: { subscriptionId: "bootstrap-test" },
});
const found = bootstrapResult.entries.find((e) => e.agent.id === agent.id);
expect(found?.agent.currentModeId).toBe("plan");
const { updates: client2Updates, unsub } = collectAgentUpdates(client2);
await ctx.client.setAgentMode(agent.id, "acceptEdits");
await sleep(500);
const modeUpdate = client2Updates.find(
(u): u is AgentUpsertPayload =>
u.kind === "upsert" &&
u.agent.id === agent.id &&
u.agent.currentModeId === "acceptEdits",
);
expect(modeUpdate).toBeDefined();
unsub();
await client2.close();
});
test("rapid mode switches settle to the final value", async () => {
const { updates, unsub } = collectAgentUpdates(ctx.client);
const agent = await ctx.client.createAgent({
provider: "claude",
cwd: "/tmp",
modeId: "default",
});
await ctx.client.setAgentMode(agent.id, "bypassPermissions");
await ctx.client.setAgentMode(agent.id, "plan");
await ctx.client.setAgentMode(agent.id, "acceptEdits");
await sleep(500);
expect(lastUpsertFor(updates, agent.id)?.agent.currentModeId).toBe("acceptEdits");
unsub();
});
test("mode switch immediately after creation reflects correct mode", async () => {
const { updates, unsub } = collectAgentUpdates(ctx.client);
const agent = await ctx.client.createAgent({
provider: "claude",
cwd: "/tmp",
modeId: "default",
});
await ctx.client.setAgentMode(agent.id, "bypassPermissions");
await sleep(500);
expect(lastUpsertFor(updates, agent.id)?.agent.currentModeId).toBe("bypassPermissions");
unsub();
});
});
@@ -0,0 +1,122 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "send-message-id-replay-"));
}
describe("send_agent_message_request replay", () => {
let ctx: DaemonTestContext;
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
}, 60_000);
test("does not start a second run when the same client messageId is replayed", async () => {
const cwd = tmpCwd();
try {
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "duplicate message replay",
modeId: "full-access",
});
const messageId = "msg-replay-1";
await ctx.client.sendMessage(agent.id, "Reply with exactly hello", { messageId });
await ctx.client.waitForFinish(agent.id, 5_000);
const afterFirst = await ctx.client.fetchAgent(agent.id);
expect(afterFirst?.agent.status).toBe("idle");
const firstUpdatedAt = afterFirst?.agent.updatedAt ?? null;
expect(firstUpdatedAt).not.toBeNull();
await ctx.client.sendMessage(agent.id, "Reply with exactly hello", { messageId });
await new Promise((resolve) => setTimeout(resolve, 50));
const afterReplay = await ctx.client.fetchAgent(agent.id);
expect(afterReplay?.agent.status).toBe("idle");
expect(afterReplay?.agent.updatedAt ?? null).toBe(firstUpdatedAt);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test("does not start a second run when identical text is replayed with a new messageId right after finish", async () => {
const cwd = tmpCwd();
try {
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "duplicate text replay after finish",
modeId: "full-access",
});
await ctx.client.sendMessage(agent.id, "Reply with exactly hello", { messageId: "msg-1" });
await ctx.client.waitForFinish(agent.id, 5_000);
const afterFirst = await ctx.client.fetchAgent(agent.id);
expect(afterFirst?.agent.status).toBe("idle");
const firstUpdatedAt = afterFirst?.agent.updatedAt ?? null;
expect(firstUpdatedAt).not.toBeNull();
await ctx.client.sendMessage(agent.id, "Reply with exactly hello", { messageId: "msg-2" });
await new Promise((resolve) => setTimeout(resolve, 50));
const afterReplay = await ctx.client.fetchAgent(agent.id);
expect(afterReplay?.agent.status).toBe("idle");
expect(afterReplay?.agent.updatedAt ?? null).toBe(firstUpdatedAt);
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 20,
projection: "canonical",
});
const userMessages = timeline.entries.filter(
(entry) => entry.item.type === "user_message" && entry.item.text === "Reply with exactly hello",
);
expect(userMessages).toHaveLength(1);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test("does not replace an active run when identical text is resent with a new messageId", async () => {
const cwd = tmpCwd();
try {
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "duplicate text replay while running",
modeId: "full-access",
});
await ctx.client.sendMessage(agent.id, "Run: sleep 30", { messageId: "msg-running-1" });
await ctx.client.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running", 5_000);
await ctx.client.sendMessage(agent.id, "Run: sleep 30", { messageId: "msg-running-2" });
const afterReplay = await ctx.client.waitForFinish(agent.id, 5_000);
expect(afterReplay.status).toBe("idle");
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 20,
projection: "canonical",
});
const userMessages = timeline.entries.filter(
(entry) => entry.item.type === "user_message" && entry.item.text === "Run: sleep 30",
);
expect(userMessages).toHaveLength(1);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
});
+46
View File
@@ -2610,6 +2610,20 @@ export class Session {
return;
}
const replayDisposition = this.agentManager.classifyRecordedUserMessage(agentId, text, {
messageId,
});
if (replayDisposition === "duplicate") {
this.sessionLogger.info(
{ agentId, messageId },
"Suppressing duplicate send replay for already-recorded user message",
);
return;
}
if (replayDisposition === "conflict") {
throw new Error(`Client messageId '${messageId}' was reused with different text`);
}
try {
this.agentManager.recordUserMessage(agentId, text, {
messageId,
@@ -6626,6 +6640,38 @@ export class Session {
await this.ensureAgentLoaded(agentId);
const replayDisposition = this.agentManager.classifyRecordedUserMessage(agentId, msg.text, {
messageId: msg.messageId,
});
if (replayDisposition === "duplicate") {
this.sessionLogger.info(
{ agentId, messageId: msg.messageId, requestId: msg.requestId },
"Suppressing duplicate send_agent_message_request replay",
);
this.emit({
type: "send_agent_message_response",
payload: {
requestId: msg.requestId,
agentId,
accepted: true,
error: null,
},
});
return;
}
if (replayDisposition === "conflict") {
this.emit({
type: "send_agent_message_response",
payload: {
requestId: msg.requestId,
agentId,
accepted: false,
error: `Client messageId '${msg.messageId}' was reused with different text`,
},
});
return;
}
try {
this.agentManager.recordUserMessage(agentId, msg.text, {
messageId: msg.messageId,
+116 -42
View File
@@ -1745,6 +1745,61 @@ async function resolveGitHubRepo(cwd: string): Promise<string | null> {
return null;
}
async function listPullRequestsForHead(options: {
cwd: string;
repo: string;
owner: string;
head: string;
state: "open" | "closed";
}): Promise<any[]> {
const { stdout } = await execFileAsync(
"gh",
[
"api",
`repos/${options.repo}/pulls`,
"-X",
"GET",
"-F",
`head=${options.owner}:${options.head}`,
"-F",
`state=${options.state}`,
],
{ cwd: options.cwd },
);
const parsed = JSON.parse(stdout.trim());
return Array.isArray(parsed) ? parsed : [];
}
function buildPullRequestStatus(current: any, fallbackHead: string): PullRequestStatus | null {
if (!current || typeof current !== "object") {
return null;
}
const url = current.html_url ?? current.url;
const title = current.title;
if (typeof url !== "string" || typeof title !== "string" || !url || !title) {
return null;
}
const mergedAt =
typeof current.merged_at === "string" && current.merged_at.trim().length > 0
? current.merged_at
: null;
const state =
mergedAt !== null
? "merged"
: typeof current.state === "string" && current.state.trim().length > 0
? current.state
: "";
return {
url,
title,
state,
baseRefName: current.base?.ref ?? "",
headRefName: current.head?.ref ?? fallbackHead,
isMerged: mergedAt !== null,
};
}
export async function createPullRequest(
cwd: string,
@@ -1789,8 +1844,9 @@ export async function createPullRequest(
export async function getPullRequestStatus(cwd: string): Promise<PullRequestStatusResult> {
await requireGitRepo(cwd);
const repo = await resolveGitHubRepo(cwd);
const head = await getCurrentBranch(cwd);
if (!head) {
if (!repo || !head) {
return {
status: null,
githubFeaturesEnabled: false,
@@ -1804,51 +1860,69 @@ export async function getPullRequestStatus(cwd: string): Promise<PullRequestStat
githubFeaturesEnabled: false,
};
}
const owner = repo.split("/")[0];
let openPulls: any[];
try {
const { stdout } = await execFileAsync(
"gh",
[
"pr",
"view",
"--json",
"url,title,state,baseRefName,headRefName,mergedAt",
],
{ cwd },
);
const pr = JSON.parse(stdout.trim());
if (!pr || typeof pr !== "object" || !pr.url || !pr.title) {
return { status: null, githubFeaturesEnabled: true };
}
const mergedAt =
typeof pr.mergedAt === "string" && pr.mergedAt.trim().length > 0
? pr.mergedAt
: null;
const state =
mergedAt !== null
? "merged"
: typeof pr.state === "string" && pr.state.trim().length > 0
? pr.state.toLowerCase()
: "";
return {
status: {
url: pr.url,
title: pr.title,
state,
baseRefName: pr.baseRefName ?? "",
headRefName: pr.headRefName ?? head,
isMerged: mergedAt !== null,
},
githubFeaturesEnabled: true,
};
openPulls = await listPullRequestsForHead({
cwd,
repo,
owner,
head,
state: "open",
});
} catch (error) {
if (isGhAuthError(error)) {
return { status: null, githubFeaturesEnabled: false };
}
// gh pr view exits non-zero when no PR exists for the branch
const message = error instanceof Error ? error.message : String(error);
if (message.includes("no pull requests found") || message.includes("Could not resolve")) {
return { status: null, githubFeaturesEnabled: true };
return {
status: null,
githubFeaturesEnabled: false,
};
}
throw error;
}
const openPull = openPulls[0] ?? null;
const openStatus = buildPullRequestStatus(openPull, head);
if (openStatus) {
return {
status: openStatus,
githubFeaturesEnabled: true,
};
}
let closedPulls: any[];
try {
closedPulls = await listPullRequestsForHead({
cwd,
repo,
owner,
head,
state: "closed",
});
} catch (error) {
if (isGhAuthError(error)) {
return {
status: null,
githubFeaturesEnabled: false,
};
}
throw error;
}
const mergedClosedPull =
closedPulls.find(
(entry) =>
entry &&
typeof entry === "object" &&
typeof entry.merged_at === "string" &&
entry.merged_at.trim().length > 0,
) ?? null;
const mergedStatus = buildPullRequestStatus(mergedClosedPull, head);
if (!mergedStatus) {
return {
status: null,
githubFeaturesEnabled: true,
};
}
return {
status: mergedStatus,
githubFeaturesEnabled: true,
};
}