Merge branch 'keyword-nav-plan'

This commit is contained in:
Mohamed Boudra
2026-02-04 13:15:04 +07:00
38 changed files with 1513 additions and 679 deletions
@@ -30,6 +30,7 @@ import { CommandAutocomplete } from "./command-autocomplete";
import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query"; import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query";
import { encodeImages } from "@/utils/encode-images"; import { encodeImages } from "@/utils/encode-images";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store"; import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { focusWithRetries } from "@/utils/web-focus";
type QueuedMessage = { type QueuedMessage = {
id: string; id: string;
@@ -363,9 +364,8 @@ export function AgentInputArea({
if (!isScreenFocused) return; if (!isScreenFocused) return;
if (!focusChatInputRequest) return; if (!focusChatInputRequest) return;
const target = focusChatInputRequest.agentKey;
const currentKey = `${serverId}:${agentId}`; const currentKey = `${serverId}:${agentId}`;
if (target !== null && target !== currentKey) { if (focusChatInputRequest.agentKey !== currentKey) {
return; return;
} }
@@ -374,12 +374,17 @@ export function AgentInputArea({
} }
lastHandledFocusRequestIdRef.current = focusChatInputRequest.id; lastHandledFocusRequestIdRef.current = focusChatInputRequest.id;
requestAnimationFrame(() => { return focusWithRetries({
requestAnimationFrame(() => { focus: () => messageInputRef.current?.focus(),
messageInputRef.current?.focus(); isFocused: () => {
const el = messageInputRef.current?.getNativeElement?.() ?? null;
const active =
typeof document !== "undefined" ? document.activeElement : null;
return Boolean(el) && active === el;
},
onSuccess: () => clearFocusChatInputRequest(),
onTimeout: () => clearFocusChatInputRequest(),
}); });
});
clearFocusChatInputRequest(focusChatInputRequest.id);
}, [ }, [
agentId, agentId,
clearFocusChatInputRequest, clearFocusChatInputRequest,
@@ -172,6 +172,8 @@ export function AgentList({
queryKey, queryKey,
queryFn: async () => await client.getCheckoutStatus(agent.cwd), queryFn: async () => await client.getCheckoutStatus(agent.cwd),
staleTime: CHECKOUT_STATUS_STALE_TIME, staleTime: CHECKOUT_STATUS_STALE_TIME,
}).catch((error) => {
console.warn("[checkout_status] prefetch failed", error);
}); });
} }
} }
@@ -45,7 +45,7 @@ import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-typ
import type { Agent } from "@/contexts/session-context"; import type { Agent } from "@/contexts/session-context";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import type { DaemonClientV2 } from "@server/client/daemon-client-v2"; import type { DaemonClient } from "@server/client/daemon-client";
import { parseToolCallDisplay } from "@/utils/tool-call-parsers"; import { parseToolCallDisplay } from "@/utils/tool-call-parsers";
import { ToolCallDetailsContent } from "./tool-call-details"; import { ToolCallDetailsContent } from "./tool-call-details";
import { ToolCallSheetProvider } from "./tool-call-sheet"; import { ToolCallSheetProvider } from "./tool-call-sheet";
@@ -796,7 +796,7 @@ function PermissionRequestCard({
client, client,
}: { }: {
permission: PendingPermission; permission: PendingPermission;
client: DaemonClientV2 | null; client: DaemonClient | null;
}) { }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isMobile = const isMobile =
+12 -145
View File
@@ -1,4 +1,3 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
Modal, Modal,
Pressable, Pressable,
@@ -9,160 +8,28 @@ import {
Platform, Platform,
} from "react-native"; } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { router, usePathname } from "expo-router"; import { useCommandCenter } from "@/hooks/use-command-center";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store"; import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { formatTimeAgo } from "@/utils/time"; import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path"; import { shortenPath } from "@/utils/shorten-path";
import { useSessionStore } from "@/stores/session-store";
import { AgentStatusDot } from "@/components/agent-status-dot"; import { AgentStatusDot } from "@/components/agent-status-dot";
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string { function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
return `${agent.serverId}:${agent.id}`; return `${agent.serverId}:${agent.id}`;
} }
function isMatch(agent: AggregatedAgent, query: string): boolean {
if (!query) return true;
const q = query.toLowerCase();
const title = (agent.title ?? "New agent").toLowerCase();
const cwd = agent.cwd.toLowerCase();
const host = agent.serverLabel.toLowerCase();
return title.includes(q) || cwd.includes(q) || host.includes(q);
}
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
const leftAttention = left.requiresAttention ? 1 : 0;
const rightAttention = right.requiresAttention ? 1 : 0;
if (leftAttention !== rightAttention) return rightAttention - leftAttention;
const leftRunning = left.status === "running" ? 1 : 0;
const rightRunning = right.status === "running" ? 1 : 0;
if (leftRunning !== rightRunning) return rightRunning - leftRunning;
return right.lastActivityAt.getTime() - left.lastActivityAt.getTime();
}
export function CommandCenter() { export function CommandCenter() {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const pathname = usePathname(); const {
const { agents } = useAggregatedAgents(); open,
const open = useKeyboardNavStore((s) => s.commandCenterOpen); inputRef,
const setOpen = useKeyboardNavStore((s) => s.setCommandCenterOpen); query,
const requestFocusChatInput = useKeyboardNavStore((s) => s.requestFocusChatInput); setQuery,
const takeFocusRestoreElement = useKeyboardNavStore((s) => s.takeFocusRestoreElement); activeIndex,
const inputRef = useRef<TextInput>(null); results,
const didNavigateRef = useRef(false); handleClose,
const prevOpenRef = useRef(open); handleSelect,
const [query, setQuery] = useState(""); } = useCommandCenter();
const [activeIndex, setActiveIndex] = useState(0);
const results = useMemo(() => {
const filtered = agents.filter((agent) => isMatch(agent, query));
filtered.sort(sortAgents);
return filtered;
}, [agents, query]);
useEffect(() => {
const prevOpen = prevOpenRef.current;
prevOpenRef.current = open;
if (!open) {
setQuery("");
setActiveIndex(0);
if (prevOpen && !didNavigateRef.current) {
const el = takeFocusRestoreElement();
if (el && el.isConnected) {
// Modal unmount can steal focus; restore on next tick.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
try {
el.focus();
} catch {
// ignore
}
});
});
}
}
return;
}
didNavigateRef.current = false;
const id = setTimeout(() => {
inputRef.current?.focus();
}, 0);
return () => clearTimeout(id);
}, [open]);
useEffect(() => {
if (!open) return;
if (activeIndex >= results.length) {
setActiveIndex(results.length > 0 ? results.length - 1 : 0);
}
}, [activeIndex, open, results.length]);
const handleClose = useCallback(() => {
setOpen(false);
}, [setOpen]);
const handleSelect = useCallback(
(agent: AggregatedAgent) => {
didNavigateRef.current = true;
const session = useSessionStore.getState().sessions[agent.serverId];
session?.client?.clearAgentAttention(agent.id);
const shouldReplace = pathname.startsWith("/agent/");
const navigate = shouldReplace ? router.replace : router.push;
requestFocusChatInput(`${agent.serverId}:${agent.id}`);
// Don't restore focus back to the prior element after we navigate.
takeFocusRestoreElement();
setOpen(false);
navigate(`/agent/${agent.serverId}/${agent.id}` as any);
},
[pathname, requestFocusChatInput, setOpen, takeFocusRestoreElement]
);
useEffect(() => {
if (!open) return;
const handler = (event: KeyboardEvent) => {
const key = event.key;
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") {
return;
}
if (key === "Escape") {
event.preventDefault();
handleClose();
return;
}
if (key === "Enter") {
if (results.length === 0) return;
event.preventDefault();
const index = Math.max(0, Math.min(activeIndex, results.length - 1));
handleSelect(results[index]!);
return;
}
if (key === "ArrowDown" || key === "ArrowUp") {
if (results.length === 0) return;
event.preventDefault();
setActiveIndex((current) => {
const delta = key === "ArrowDown" ? 1 : -1;
const next = current + delta;
if (next < 0) return results.length - 1;
if (next >= results.length) return 0;
return next;
});
}
};
// react-native-web can stop propagation on key events, so listen in capture phase.
window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true);
}, [activeIndex, handleClose, handleSelect, open, results]);
if (Platform.OS !== "web") return null; if (Platform.OS !== "web") return null;
@@ -302,6 +302,8 @@ export function GroupedAgentList({
queryKey, queryKey,
queryFn: async () => await client.getCheckoutStatus(agent.cwd), queryFn: async () => await client.getCheckoutStatus(agent.cwd),
staleTime: CHECKOUT_STATUS_STALE_TIME, staleTime: CHECKOUT_STATUS_STALE_TIME,
}).catch((error) => {
console.warn("[checkout_status] prefetch failed", error);
}); });
} }
}, [agents, queryClient]); }, [agents, queryClient]);
@@ -325,7 +327,9 @@ export function GroupedAgentList({
const session = useSessionStore.getState().sessions[agent.serverId]; const session = useSessionStore.getState().sessions[agent.serverId];
const client = session?.client ?? null; const client = session?.client ?? null;
if (client) { if (client) {
void client.archiveAgent(agent.id); void client.archiveAgent(agent.id).catch((error) => {
console.warn("[archive_agent] failed", error);
});
} }
}, },
[] []
+18 -2
View File
@@ -26,7 +26,7 @@ import Animated, {
} from "react-native-reanimated"; } from "react-native-reanimated";
import { useDictation } from "@/hooks/use-dictation"; import { useDictation } from "@/hooks/use-dictation";
import { DictationOverlay } from "./dictation-controls"; import { DictationOverlay } from "./dictation-controls";
import type { DaemonClientV2 } from "@server/client/daemon-client-v2"; import type { DaemonClient } from "@server/client/daemon-client";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { useVoiceOptional } from "@/contexts/voice-context"; import { useVoiceOptional } from "@/contexts/voice-context";
@@ -51,7 +51,7 @@ export interface MessageInputProps {
images?: ImageAttachment[]; images?: ImageAttachment[];
onPickImages?: () => void; onPickImages?: () => void;
onRemoveImage?: (index: number) => void; onRemoveImage?: (index: number) => void;
client: DaemonClientV2 | null; client: DaemonClient | null;
placeholder?: string; placeholder?: string;
autoFocus?: boolean; autoFocus?: boolean;
disabled?: boolean; disabled?: boolean;
@@ -72,6 +72,11 @@ export interface MessageInputProps {
export interface MessageInputRef { export interface MessageInputRef {
focus: () => void; focus: () => void;
blur: () => void; blur: () => void;
/**
* Web-only: return the underlying DOM element for focus assertions/retries.
* May return null if not mounted or on native.
*/
getNativeElement?: () => HTMLElement | null;
} }
const MIN_INPUT_HEIGHT = 30; const MIN_INPUT_HEIGHT = 30;
@@ -135,6 +140,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
blur: () => { blur: () => {
textInputRef.current?.blur?.(); textInputRef.current?.blur?.();
}, },
getNativeElement: () => {
if (!IS_WEB) return null;
const current = textInputRef.current as
| (TextInput & { getNativeRef?: () => unknown })
| null;
const native =
typeof current?.getNativeRef === "function"
? current.getNativeRef()
: current;
return native instanceof HTMLElement ? native : null;
},
})); }));
const inputHeightRef = useRef(MIN_INPUT_HEIGHT); const inputHeightRef = useRef(MIN_INPUT_HEIGHT);
const baselineInputHeightRef = useRef<number | null>(null); const baselineInputHeightRef = useRef<number | null>(null);
@@ -20,7 +20,7 @@ import type {
} from "@server/shared/messages"; } from "@server/shared/messages";
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle"; import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
import type { AgentPermissionRequest } from "@server/server/agent/agent-sdk-types"; import type { AgentPermissionRequest } from "@server/server/agent/agent-sdk-types";
import type { DaemonClientV2, ConnectionState } from "@server/client/daemon-client-v2"; import type { DaemonClient, ConnectionState } from "@server/client/daemon-client";
import { File } from "expo-file-system"; import { File } from "expo-file-system";
import { useDaemonConnections } from "./daemon-connections-context"; import { useDaemonConnections } from "./daemon-connections-context";
import { import {
@@ -1,8 +1,8 @@
import { generateMessageId } from "@/types/stream"; import { generateMessageId } from "@/types/stream";
import type { DaemonClientV2 } from "@server/client/daemon-client-v2"; import type { DaemonClient } from "@server/client/daemon-client";
export type DictationStreamSenderParams = { export type DictationStreamSenderParams = {
client: DaemonClientV2 | null; client: DaemonClient | null;
format: string; format: string;
createDictationId?: () => string; createDictationId?: () => string;
}; };
@@ -22,7 +22,7 @@ type DictationFinishResult = { dictationId: string; text: string };
* so enqueues can't "miss" a flush due to in-flight await/coalescing bugs. * so enqueues can't "miss" a flush due to in-flight await/coalescing bugs.
*/ */
export class DictationStreamSender { export class DictationStreamSender {
private client: DaemonClientV2 | null; private client: DaemonClient | null;
private readonly format: string; private readonly format: string;
private readonly createDictationId: () => string; private readonly createDictationId: () => string;
@@ -40,7 +40,7 @@ export class DictationStreamSender {
this.createDictationId = params.createDictationId ?? generateMessageId; this.createDictationId = params.createDictationId ?? generateMessageId;
} }
setClient(client: DaemonClientV2 | null): void { setClient(client: DaemonClient | null): void {
this.client = client; this.client = client;
} }
@@ -1,12 +1,12 @@
import { useEffect, useRef, useCallback } from "react"; import { useEffect, useRef, useCallback } from "react";
import { AppState, Platform } from "react-native"; import { AppState, Platform } from "react-native";
import type { DaemonClientV2 } from "@server/client/daemon-client-v2"; import type { DaemonClient } from "@server/client/daemon-client";
const HEARTBEAT_INTERVAL_MS = 15_000; const HEARTBEAT_INTERVAL_MS = 15_000;
const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000; const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000;
interface ClientActivityOptions { interface ClientActivityOptions {
client: DaemonClientV2; client: DaemonClient;
focusedAgentId: string | null; focusedAgentId: string | null;
} }
@@ -0,0 +1,192 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { TextInput } from "react-native";
import { router, usePathname } from "expo-router";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store";
import {
clearCommandCenterFocusRestoreElement,
takeCommandCenterFocusRestoreElement,
} from "@/utils/command-center-focus-restore";
import { focusWithRetries } from "@/utils/web-focus";
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
return `${agent.serverId}:${agent.id}`;
}
function isMatch(agent: AggregatedAgent, query: string): boolean {
if (!query) return true;
const q = query.toLowerCase();
const title = (agent.title ?? "New agent").toLowerCase();
const cwd = agent.cwd.toLowerCase();
const host = agent.serverLabel.toLowerCase();
return title.includes(q) || cwd.includes(q) || host.includes(q);
}
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
const leftAttention = left.requiresAttention ? 1 : 0;
const rightAttention = right.requiresAttention ? 1 : 0;
if (leftAttention !== rightAttention) return rightAttention - leftAttention;
const leftRunning = left.status === "running" ? 1 : 0;
const rightRunning = right.status === "running" ? 1 : 0;
if (leftRunning !== rightRunning) return rightRunning - leftRunning;
return right.lastActivityAt.getTime() - left.lastActivityAt.getTime();
}
function parseAgentKeyFromPathname(pathname: string): string | null {
const match = pathname.match(/^\/agent\/([^/]+)\/([^/]+)/);
if (!match) return null;
return `${match[1]}:${match[2]}`;
}
export function useCommandCenter() {
const pathname = usePathname();
const { agents } = useAggregatedAgents();
const open = useKeyboardNavStore((s) => s.commandCenterOpen);
const setOpen = useKeyboardNavStore((s) => s.setCommandCenterOpen);
const requestFocusChatInput = useKeyboardNavStore((s) => s.requestFocusChatInput);
const inputRef = useRef<TextInput>(null);
const didNavigateRef = useRef(false);
const prevOpenRef = useRef(open);
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const results = useMemo(() => {
const filtered = agents.filter((agent) => isMatch(agent, query));
filtered.sort(sortAgents);
return filtered;
}, [agents, query]);
const agentKeyFromPathname = useMemo(
() => parseAgentKeyFromPathname(pathname),
[pathname]
);
const handleClose = useCallback(() => {
setOpen(false);
}, [setOpen]);
const handleSelect = useCallback(
(agent: AggregatedAgent) => {
didNavigateRef.current = true;
const session = useSessionStore.getState().sessions[agent.serverId];
session?.client?.clearAgentAttention(agent.id);
const shouldReplace = pathname.startsWith("/agent/");
const navigate = shouldReplace ? router.replace : router.push;
requestFocusChatInput(agentKey(agent));
// Don't restore focus back to the prior element after we navigate.
clearCommandCenterFocusRestoreElement();
setOpen(false);
navigate(`/agent/${agent.serverId}/${agent.id}` as any);
},
[pathname, requestFocusChatInput, setOpen]
);
useEffect(() => {
const prevOpen = prevOpenRef.current;
prevOpenRef.current = open;
if (!open) {
setQuery("");
setActiveIndex(0);
if (prevOpen && !didNavigateRef.current) {
const el = takeCommandCenterFocusRestoreElement();
const isFocused = () =>
Boolean(el) &&
typeof document !== "undefined" &&
document.activeElement === el;
const cancel = focusWithRetries({
focus: () => el?.focus(),
isFocused,
onTimeout: () => {
if (agentKeyFromPathname) {
requestFocusChatInput(agentKeyFromPathname);
}
},
});
return cancel;
}
return;
}
didNavigateRef.current = false;
const id = setTimeout(() => {
inputRef.current?.focus();
}, 0);
return () => clearTimeout(id);
}, [agentKeyFromPathname, open, requestFocusChatInput]);
useEffect(() => {
if (!open) return;
if (activeIndex >= results.length) {
setActiveIndex(results.length > 0 ? results.length - 1 : 0);
}
}, [activeIndex, open, results.length]);
useEffect(() => {
if (!open) return;
const handler = (event: KeyboardEvent) => {
const key = event.key;
if (
key !== "ArrowDown" &&
key !== "ArrowUp" &&
key !== "Enter" &&
key !== "Escape"
) {
return;
}
if (key === "Escape") {
event.preventDefault();
handleClose();
return;
}
if (key === "Enter") {
if (results.length === 0) return;
event.preventDefault();
const index = Math.max(0, Math.min(activeIndex, results.length - 1));
handleSelect(results[index]!);
return;
}
if (key === "ArrowDown" || key === "ArrowUp") {
if (results.length === 0) return;
event.preventDefault();
setActiveIndex((current) => {
const delta = key === "ArrowDown" ? 1 : -1;
const next = current + delta;
if (next < 0) return results.length - 1;
if (next >= results.length) return 0;
return next;
});
}
};
// react-native-web can stop propagation on key events, so listen in capture phase.
window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true);
}, [activeIndex, handleClose, handleSelect, open, results]);
return {
open,
inputRef,
query,
setQuery,
activeIndex,
setActiveIndex,
results,
handleClose,
handleSelect,
};
}
+3 -3
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { AppState } from "react-native"; import { AppState } from "react-native";
import { DaemonClientV2 } from "@server/client/daemon-client-v2"; import { DaemonClient } from "@server/client/daemon-client";
import { createTauriWebSocketTransportFactory } from "@/utils/tauri-daemon-transport"; import { createTauriWebSocketTransportFactory } from "@/utils/tauri-daemon-transport";
function runDaemonRequest(label: string, promise: Promise<unknown>): void { function runDaemonRequest(label: string, promise: Promise<unknown>): void {
@@ -9,11 +9,11 @@ function runDaemonRequest(label: string, promise: Promise<unknown>): void {
}); });
} }
export function useDaemonClient(url: string): DaemonClientV2 { export function useDaemonClient(url: string): DaemonClient {
const client = useMemo( const client = useMemo(
() => { () => {
const tauriTransportFactory = createTauriWebSocketTransportFactory(); const tauriTransportFactory = createTauriWebSocketTransportFactory();
return new DaemonClientV2({ return new DaemonClient({
url, url,
suppressSendErrors: true, suppressSendErrors: true,
...(tauriTransportFactory ...(tauriTransportFactory
@@ -1,7 +1,7 @@
export type DictationStatus = "idle" | "recording" | "uploading" | "failed"; export type DictationStatus = "idle" | "recording" | "uploading" | "failed";
export type UseDictationOptions = { export type UseDictationOptions = {
client: import("@server/client/daemon-client-v2").DaemonClientV2 | null; client: import("@server/client/daemon-client").DaemonClient | null;
onTranscript: (text: string, meta: { requestId: string }) => void; onTranscript: (text: string, meta: { requestId: string }) => void;
onPartialTranscript?: (text: string, meta: { requestId: string }) => void; onPartialTranscript?: (text: string, meta: { requestId: string }) => void;
onError?: (error: Error) => void; onError?: (error: Error) => void;
@@ -4,6 +4,7 @@ import { usePathname, useRouter } from "expo-router";
import { getIsTauri } from "@/constants/layout"; import { getIsTauri } from "@/constants/layout";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store"; import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { parseSidebarAgentKey } from "@/utils/sidebar-shortcuts"; import { parseSidebarAgentKey } from "@/utils/sidebar-shortcuts";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
export function useGlobalKeyboardNav({ export function useGlobalKeyboardNav({
enabled, enabled,
@@ -34,6 +35,17 @@ export function useGlobalKeyboardNav({
return true; return true;
}; };
const isEditableTarget = (event: KeyboardEvent): boolean => {
const target = event.target;
if (!(target instanceof Element)) return false;
if ((target as HTMLElement).isContentEditable) return true;
const tag = target.tagName.toLowerCase();
if (tag === "input" || tag === "textarea") return true;
return false;
};
const parseShortcutDigit = (event: KeyboardEvent): number | null => { const parseShortcutDigit = (event: KeyboardEvent): number | null => {
const code = event.code ?? ""; const code = event.code ?? "";
if (code.startsWith("Digit")) { if (code.startsWith("Digit")) {
@@ -77,14 +89,6 @@ export function useGlobalKeyboardNav({
const key = event.key ?? ""; const key = event.key ?? "";
const lowerKey = key.toLowerCase(); const lowerKey = key.toLowerCase();
const target = event.target as unknown;
const isEditableTarget =
typeof HTMLElement !== "undefined" &&
target instanceof HTMLElement &&
(target.tagName === "TEXTAREA" ||
target.tagName === "INPUT" ||
target.isContentEditable);
if (key === "Alt") { if (key === "Alt") {
useKeyboardNavStore.getState().setAltDown(true); useKeyboardNavStore.getState().setAltDown(true);
} }
@@ -97,9 +101,24 @@ export function useGlobalKeyboardNav({
(event.metaKey || event.ctrlKey) && (event.metaKey || event.ctrlKey) &&
(event.code === "KeyB" || lowerKey === "b") (event.code === "KeyB" || lowerKey === "b")
) { ) {
// When focus is in the chat input, MessageInput handles Cmd+B (and prevents default) // The MessageInput already handles Cmd+B inside editable fields. If we also
// via TextInput onKeyPress. If we also toggle here, it flips twice and appears "broken". // handle it globally, it can double-toggle and look like it "doesn't work".
if (isEditableTarget) { if (isEditableTarget(event)) {
return;
}
event.preventDefault();
toggleAgentList();
return;
}
// Cmd+.: toggle sidebar (VS Code quick-fix muscle memory)
// Note: intentionally works even when focus is inside an input/textarea.
if (
(event.metaKey || event.ctrlKey) &&
(event.code === "Period" || key === ".")
) {
// Ignore while command center is open.
if (useKeyboardNavStore.getState().commandCenterOpen) {
return; return;
} }
event.preventDefault(); event.preventDefault();
@@ -115,7 +134,25 @@ export function useGlobalKeyboardNav({
(event.code === "KeyE" || lowerKey === "e") (event.code === "KeyE" || lowerKey === "e")
) { ) {
// Same double-toggle issue as Cmd+B when focus is inside a text input. // Same double-toggle issue as Cmd+B when focus is inside a text input.
if (isEditableTarget) { if (isEditableTarget(event)) {
return;
}
event.preventDefault();
toggleFileExplorer();
return;
}
// Ctrl+`: toggle explorer sidebar (VS Code muscle memory)
// Note: intentionally works even when focus is inside an input/textarea.
if (
selectedAgentId &&
toggleFileExplorer &&
event.ctrlKey &&
!event.metaKey &&
(event.code === "Backquote" || key === "`")
) {
// Ignore while command center is open.
if (useKeyboardNavStore.getState().commandCenterOpen) {
return; return;
} }
event.preventDefault(); event.preventDefault();
@@ -128,18 +165,18 @@ export function useGlobalKeyboardNav({
event.preventDefault(); event.preventDefault();
const s = useKeyboardNavStore.getState(); const s = useKeyboardNavStore.getState();
if (!s.commandCenterOpen) { if (!s.commandCenterOpen) {
const target = event.target as unknown; const target =
const el = event.target instanceof Element ? (event.target as Element) : null;
typeof HTMLElement !== "undefined" && target instanceof HTMLElement const targetEl =
? target target?.closest?.("textarea, input, [contenteditable='true']") ??
: (typeof document !== "undefined" (target instanceof HTMLElement ? target : null);
? (document.activeElement as HTMLElement | null) const active = document.activeElement;
: null); const activeEl = active instanceof HTMLElement ? active : null;
s.setFocusRestoreElement(el); setCommandCenterFocusRestoreElement(
s.setCommandCenterOpen(true); (targetEl as HTMLElement | null) ?? activeEl ?? null
} else { );
s.setCommandCenterOpen(false);
} }
s.setCommandCenterOpen(!s.commandCenterOpen);
return; return;
} }
@@ -3,7 +3,7 @@ import { Platform } from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Notifications from "expo-notifications"; import * as Notifications from "expo-notifications";
import Constants from "expo-constants"; import Constants from "expo-constants";
import type { DaemonClientV2 } from "@server/client/daemon-client-v2"; import type { DaemonClient } from "@server/client/daemon-client";
const STORAGE_PREFIX = "@paseo:expo-push-token:"; const STORAGE_PREFIX = "@paseo:expo-push-token:";
@@ -26,7 +26,7 @@ async function ensurePushPermission(): Promise<boolean> {
} }
export function usePushTokenRegistration(params: { export function usePushTokenRegistration(params: {
client: DaemonClientV2; client: DaemonClient;
serverId: string; serverId: string;
}): void { }): void {
const { client, serverId } = params; const { client, serverId } = params;
@@ -1,7 +1,6 @@
import { useEffect, useMemo } from "react"; import { useEffect, useMemo, useState } from "react";
import { useQueries, type UseQueryOptions } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { import {
CHECKOUT_STATUS_STALE_TIME,
checkoutStatusQueryKey, checkoutStatusQueryKey,
type CheckoutStatusPayload, type CheckoutStatusPayload,
} from "@/hooks/use-checkout-status-query"; } from "@/hooks/use-checkout-status-query";
@@ -23,32 +22,32 @@ export interface SidebarSectionData {
} }
export function useSidebarAgentSections(agents: AggregatedAgent[]): SidebarSectionData[] { export function useSidebarAgentSections(agents: AggregatedAgent[]): SidebarSectionData[] {
const checkoutCacheQueries = useQueries({ const queryClient = useQueryClient();
queries: agents.map( const [checkoutCacheBump, setCheckoutCacheBump] = useState(0);
(agent): UseQueryOptions<CheckoutStatusPayload> => ({
queryKey: checkoutStatusQueryKey(agent.serverId, agent.cwd), // Re-render when checkout status cache updates so grouping can switch from cwd→remote.
enabled: false, useEffect(() => {
staleTime: CHECKOUT_STATUS_STALE_TIME, const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
queryFn: async (): Promise<CheckoutStatusPayload> => { const queryKey = event?.query?.queryKey;
throw new Error("checkout status cache-only query should not run"); if (!Array.isArray(queryKey) || queryKey[0] !== "checkoutStatus") {
}, return;
}) }
), setCheckoutCacheBump((v) => v + 1);
}); });
return unsubscribe;
}, [queryClient]);
const remoteUrlByAgentKey = useMemo(() => { const remoteUrlByAgentKey = useMemo(() => {
const result = new Map<string, string | null>(); const result = new Map<string, string | null>();
for (let i = 0; i < agents.length; i++) { for (const agent of agents) {
const agent = agents[i]; const checkout =
if (!agent) { queryClient.getQueryData<CheckoutStatusPayload>(
continue; checkoutStatusQueryKey(agent.serverId, agent.cwd)
} ) ?? null;
const checkout = checkoutCacheQueries[i]?.data ?? null; result.set(`${agent.serverId}:${agent.id}`, checkout?.remoteUrl ?? null);
const remoteUrl = checkout?.remoteUrl ?? null;
result.set(`${agent.serverId}:${agent.id}`, remoteUrl);
} }
return result; return result;
}, [agents, checkoutCacheQueries]); }, [agents, checkoutCacheBump, queryClient]);
const projectOrder = useSectionOrderStore((state) => state.projectOrder); const projectOrder = useSectionOrderStore((state) => state.projectOrder);
const setProjectOrder = useSectionOrderStore((state) => state.setProjectOrder); const setProjectOrder = useSectionOrderStore((state) => state.setProjectOrder);
@@ -100,4 +99,3 @@ export function useSidebarAgentSections(agents: AggregatedAgent[]): SidebarSecti
return sections; return sections;
} }
+19 -32
View File
@@ -1,60 +1,47 @@
import { create } from "zustand"; import { create } from "zustand";
type FocusChatInputRequest = {
id: number;
agentKey: string;
};
interface KeyboardNavState { interface KeyboardNavState {
commandCenterOpen: boolean; commandCenterOpen: boolean;
altDown: boolean; altDown: boolean;
cmdOrCtrlDown: boolean; cmdOrCtrlDown: boolean;
/** Sidebar-visible agent keys (up to 9), in top-to-bottom visual order. */ /** Sidebar-visible agent keys (up to 9), in top-to-bottom visual order. */
sidebarShortcutAgentKeys: string[]; sidebarShortcutAgentKeys: string[];
/** Captured focus target when opening the command center (web only). */
focusRestoreElement: HTMLElement | null; /** Web-only request to focus the MessageInput for the selected agent. */
focusChatInputRequest: { id: number; agentKey: string | null } | null; focusChatInputRequest: FocusChatInputRequest | null;
requestFocusChatInput: (agentKey: string) => void;
clearFocusChatInputRequest: () => void;
setCommandCenterOpen: (open: boolean) => void; setCommandCenterOpen: (open: boolean) => void;
setAltDown: (down: boolean) => void; setAltDown: (down: boolean) => void;
setCmdOrCtrlDown: (down: boolean) => void; setCmdOrCtrlDown: (down: boolean) => void;
setSidebarShortcutAgentKeys: (keys: string[]) => void; setSidebarShortcutAgentKeys: (keys: string[]) => void;
setFocusRestoreElement: (el: HTMLElement | null) => void;
takeFocusRestoreElement: () => HTMLElement | null;
requestFocusChatInput: (agentKey: string | null) => void;
clearFocusChatInputRequest: (id: number) => void;
resetModifiers: () => void; resetModifiers: () => void;
} }
export const useKeyboardNavStore = create<KeyboardNavState>((set) => ({ export const useKeyboardNavStore = create<KeyboardNavState>((set, get) => ({
commandCenterOpen: false, commandCenterOpen: false,
altDown: false, altDown: false,
cmdOrCtrlDown: false, cmdOrCtrlDown: false,
sidebarShortcutAgentKeys: [], sidebarShortcutAgentKeys: [],
focusRestoreElement: null,
focusChatInputRequest: null, focusChatInputRequest: null,
requestFocusChatInput: (agentKey) => {
const prev = get().focusChatInputRequest;
const id = (prev?.id ?? 0) + 1;
set({ focusChatInputRequest: { id, agentKey } });
},
clearFocusChatInputRequest: () => set({ focusChatInputRequest: null }),
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }), setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
setAltDown: (down) => set({ altDown: down }), setAltDown: (down) => set({ altDown: down }),
setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }), setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }),
setSidebarShortcutAgentKeys: (keys) => set({ sidebarShortcutAgentKeys: keys }), setSidebarShortcutAgentKeys: (keys) => set({ sidebarShortcutAgentKeys: keys }),
setFocusRestoreElement: (el) => set({ focusRestoreElement: el }),
takeFocusRestoreElement: () => {
let value: HTMLElement | null = null;
set((state) => {
value = state.focusRestoreElement;
return { focusRestoreElement: null };
});
return value;
},
requestFocusChatInput: (agentKey) =>
set((state) => ({
focusChatInputRequest: {
id: (state.focusChatInputRequest?.id ?? 0) + 1,
agentKey,
},
})),
clearFocusChatInputRequest: (id) =>
set((state) => {
if (state.focusChatInputRequest?.id !== id) {
return state;
}
return { focusChatInputRequest: null };
}),
resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }), resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }),
})); }));
+6 -6
View File
@@ -1,6 +1,6 @@
import { create } from "zustand"; import { create } from "zustand";
import { subscribeWithSelector } from "zustand/middleware"; import { subscribeWithSelector } from "zustand/middleware";
import type { DaemonClientV2 } from "@server/client/daemon-client-v2"; import type { DaemonClient } from "@server/client/daemon-client";
import type { useAudioPlayer } from "@/hooks/use-audio-player"; import type { useAudioPlayer } from "@/hooks/use-audio-player";
import type { AgentDirectoryEntry } from "@/types/agent-directory"; import type { AgentDirectoryEntry } from "@/types/agent-directory";
import type { StreamItem } from "@/types/stream"; import type { StreamItem } from "@/types/stream";
@@ -151,7 +151,7 @@ export interface SessionState {
serverId: string; serverId: string;
// Daemon client (immutable reference) // Daemon client (immutable reference)
client: DaemonClientV2 | null; client: DaemonClient | null;
// Connection snapshot (mutable) // Connection snapshot (mutable)
connection: DaemonConnectionSnapshot; connection: DaemonConnectionSnapshot;
@@ -206,10 +206,10 @@ interface SessionStoreState {
// Action types // Action types
interface SessionStoreActions { interface SessionStoreActions {
// Session management // Session management
initializeSession: (serverId: string, client: DaemonClientV2, audioPlayer: ReturnType<typeof useAudioPlayer>) => void; initializeSession: (serverId: string, client: DaemonClient, audioPlayer: ReturnType<typeof useAudioPlayer>) => void;
clearSession: (serverId: string) => void; clearSession: (serverId: string) => void;
getSession: (serverId: string) => SessionState | undefined; getSession: (serverId: string) => SessionState | undefined;
updateSessionClient: (serverId: string, client: DaemonClientV2) => void; updateSessionClient: (serverId: string, client: DaemonClient) => void;
updateSessionConnection: (serverId: string, connection: DaemonConnectionSnapshot) => void; updateSessionConnection: (serverId: string, connection: DaemonConnectionSnapshot) => void;
// Audio state // Audio state
@@ -281,7 +281,7 @@ function logSessionStoreUpdate(
} }
function createDefaultConnectionSnapshot(client?: DaemonClientV2 | null): DaemonConnectionSnapshot { function createDefaultConnectionSnapshot(client?: DaemonClient | null): DaemonConnectionSnapshot {
if (!client) { if (!client) {
return { isConnected: false, isConnecting: false, lastError: null }; return { isConnected: false, isConnecting: false, lastError: null };
} }
@@ -294,7 +294,7 @@ function createDefaultConnectionSnapshot(client?: DaemonClientV2 | null): Daemon
} }
// Helper to create initial session state // Helper to create initial session state
function createInitialSessionState(serverId: string, client: DaemonClientV2, audioPlayer: ReturnType<typeof useAudioPlayer>): SessionState { function createInitialSessionState(serverId: string, client: DaemonClient, audioPlayer: ReturnType<typeof useAudioPlayer>): SessionState {
return { return {
serverId, serverId,
client, client,
@@ -0,0 +1,16 @@
let focusRestoreElement: HTMLElement | null = null;
export function setCommandCenterFocusRestoreElement(el: HTMLElement | null): void {
focusRestoreElement = el;
}
export function takeCommandCenterFocusRestoreElement(): HTMLElement | null {
const el = focusRestoreElement;
focusRestoreElement = null;
return el;
}
export function clearCommandCenterFocusRestoreElement(): void {
focusRestoreElement = null;
}
@@ -1,4 +1,4 @@
import type { DaemonTransport, DaemonTransportFactory } from "@server/client/daemon-client-v2"; import type { DaemonTransport, DaemonTransportFactory } from "@server/client/daemon-client";
type TauriWebSocketMessage = type TauriWebSocketMessage =
| { type: "Text"; data: string } | { type: "Text"; data: string }
@@ -1,5 +1,5 @@
import { DaemonClientV2 } from "@server/client/daemon-client-v2"; import { DaemonClient } from "@server/client/daemon-client";
import type { ConnectionState } from "@server/client/daemon-client-v2"; import type { ConnectionState } from "@server/client/daemon-client";
import { buildDaemonWebSocketUrl } from "./daemon-endpoints"; import { buildDaemonWebSocketUrl } from "./daemon-endpoints";
function normalizeNonEmptyString(value: unknown): string | null { function normalizeNonEmptyString(value: unknown): string | null {
@@ -45,7 +45,7 @@ export async function testDaemonEndpointConnection(
const timeoutMs = options?.timeoutMs ?? 6000; const timeoutMs = options?.timeoutMs ?? 6000;
const url = buildDaemonWebSocketUrl(endpoint); const url = buildDaemonWebSocketUrl(endpoint);
const client = new DaemonClientV2({ const client = new DaemonClient({
url, url,
suppressSendErrors: true, suppressSendErrors: true,
}); });
+51
View File
@@ -0,0 +1,51 @@
type FocusWithRetriesOptions = {
focus: () => void;
isFocused: () => boolean;
timeoutMs?: number;
onSuccess?: () => void;
onTimeout?: () => void;
};
export function focusWithRetries({
focus,
isFocused,
timeoutMs = 1500,
onSuccess,
onTimeout,
}: FocusWithRetriesOptions): () => void {
let cancelled = false;
const deadlineMs = Date.now() + timeoutMs;
const tick = () => {
if (cancelled) return;
try {
focus();
} catch {
// ignore
}
if (isFocused()) {
onSuccess?.();
return;
}
if (Date.now() >= deadlineMs) {
onTimeout?.();
return;
}
requestAnimationFrame(() => {
requestAnimationFrame(tick);
});
};
requestAnimationFrame(() => {
requestAnimationFrame(tick);
});
return () => {
cancelled = true;
};
}
+1 -1
View File
@@ -11,7 +11,7 @@
- `loadConfig`, `resolvePaseoHome` - `loadConfig`, `resolvePaseoHome`
- `createRootLogger`, `LogLevel`, `LogFormat` - `createRootLogger`, `LogLevel`, `LogFormat`
- `loadPersistedConfig`, `PersistedConfig` - `loadPersistedConfig`, `PersistedConfig`
- `DaemonClientV2`, `DaemonClientV2Config`, `ConnectionState`, `DaemonEvent` - `DaemonClient`, `DaemonClientConfig`, `ConnectionState`, `DaemonEvent`
No agent snapshot/timeline/permission/message types are exported. No agent snapshot/timeline/permission/message types are exported.
+2 -2
View File
@@ -1,7 +1,7 @@
import type { Command } from 'commander' import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js' import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { import type {
DaemonClientV2, DaemonClient,
AgentStreamMessage, AgentStreamMessage,
AgentStreamSnapshotMessage, AgentStreamSnapshotMessage,
AgentStreamEventPayload, AgentStreamEventPayload,
@@ -107,7 +107,7 @@ export async function runAttachCommand(
process.exit(1) process.exit(1)
} }
let client: DaemonClientV2 let client: DaemonClient
try { try {
client = await connectToDaemon({ host: options.host as string | undefined }) client = await connectToDaemon({ host: options.host as string | undefined })
} catch (err) { } catch (err) {
+3 -3
View File
@@ -2,7 +2,7 @@ import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js' import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions } from '../../output/index.js' import type { CommandOptions } from '../../output/index.js'
import type { import type {
DaemonClientV2, DaemonClient,
AgentStreamMessage, AgentStreamMessage,
AgentStreamSnapshotMessage, AgentStreamSnapshotMessage,
AgentTimelineItem, AgentTimelineItem,
@@ -80,7 +80,7 @@ export async function runLogsCommand(
process.exit(1) process.exit(1)
} }
let client: DaemonClientV2 let client: DaemonClient
try { try {
client = await connectToDaemon({ host: options.host as string | undefined }) client = await connectToDaemon({ host: options.host as string | undefined })
} catch (err) { } catch (err) {
@@ -172,7 +172,7 @@ export async function runLogsCommand(
* Follow mode: stream logs in real-time until interrupted * Follow mode: stream logs in real-time until interrupted
*/ */
async function runFollowMode( async function runFollowMode(
client: DaemonClientV2, client: DaemonClient,
agentId: string, agentId: string,
options: AgentLogsOptions options: AgentLogsOptions
): Promise<void> { ): Promise<void> {
+4 -4
View File
@@ -1,4 +1,4 @@
import { DaemonClientV2 } from '@paseo/server' import { DaemonClient } from '@paseo/server'
import WebSocket from 'ws' import WebSocket from 'ws'
export interface ConnectOptions { export interface ConnectOptions {
@@ -35,12 +35,12 @@ function createNodeWebSocketFactory() {
* Create and connect a daemon client * Create and connect a daemon client
* Returns the connected client or throws if connection fails * Returns the connected client or throws if connection fails
*/ */
export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonClientV2> { export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonClient> {
const host = getDaemonHost(options) const host = getDaemonHost(options)
const timeout = options?.timeout ?? DEFAULT_TIMEOUT const timeout = options?.timeout ?? DEFAULT_TIMEOUT
const url = `ws://${host}/ws` const url = `ws://${host}/ws`
const client = new DaemonClientV2({ const client = new DaemonClient({
url, url,
webSocketFactory: createNodeWebSocketFactory(), webSocketFactory: createNodeWebSocketFactory(),
reconnect: { enabled: false }, reconnect: { enabled: false },
@@ -76,7 +76,7 @@ export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonC
/** /**
* Try to connect to the daemon, returns null if connection fails * Try to connect to the daemon, returns null if connection fails
*/ */
export async function tryConnectToDaemon(options?: ConnectOptions): Promise<DaemonClientV2 | null> { export async function tryConnectToDaemon(options?: ConnectOptions): Promise<DaemonClient | null> {
try { try {
return await connectToDaemon(options) return await connectToDaemon(options)
} catch { } catch {
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { DaemonClientV2, type DaemonTransport } from "./daemon-client-v2"; import { DaemonClient, type DaemonTransport } from "./daemon-client";
function createMockLogger() { function createMockLogger() {
return { return {
@@ -49,8 +49,8 @@ function createMockTransport() {
}; };
} }
describe("DaemonClientV2", () => { describe("DaemonClient", () => {
const clients: DaemonClientV2[] = []; const clients: DaemonClient[] = [];
afterEach(async () => { afterEach(async () => {
for (const client of clients) { for (const client of clients) {
@@ -63,7 +63,7 @@ describe("DaemonClientV2", () => {
const logger = createMockLogger(); const logger = createMockLogger();
const mock = createMockTransport(); const mock = createMockTransport();
const client = new DaemonClientV2({ const client = new DaemonClient({
url: "ws://test", url: "ws://test",
logger, logger,
reconnect: { enabled: false }, reconnect: { enabled: false },
@@ -137,4 +137,38 @@ describe("DaemonClientV2", () => {
isGit: false, isGit: false,
}); });
}); });
test("cancels waiters when send fails (no leaked timeouts)", async () => {
vi.useFakeTimers();
const logger = createMockLogger();
const mock = createMockTransport();
const transportFactory = () => ({
...mock.transport,
send: () => {
throw new Error("boom");
},
});
const client = new DaemonClient({
url: "ws://test",
logger,
reconnect: { enabled: false },
transportFactory,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.getCheckoutStatus("/tmp/project");
await expect(promise).rejects.toThrow("boom");
// Ensure we didn't leave a waiter behind that will reject later.
expect((client as any).waiters.size).toBe(0);
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
}); });
@@ -326,7 +326,7 @@ describe("Codex app-server provider (integration)", () => {
"*** Add File: patch.txt", "*** Add File: patch.txt",
"+patched", "+patched",
"*** End Patch", "*** End Patch",
].join("\\n"); ].join("\n");
const patchEvents = session.stream( const patchEvents = session.stream(
[ [
"Use the apply_patch tool and nothing else.", "Use the apply_patch tool and nothing else.",
@@ -335,7 +335,7 @@ describe("Codex app-server provider (integration)", () => {
"Apply the following patch exactly:", "Apply the following patch exactly:",
patch, patch,
"After it completes, reply PATCH_DONE.", "After it completes, reply PATCH_DONE.",
].join("\\n") ].join("\n")
); );
for await (const event of patchEvents) { for await (const event of patchEvents) {
@@ -612,7 +612,7 @@ describe("Codex app-server provider (integration)", () => {
"*** Add File: approval-test.txt", "*** Add File: approval-test.txt",
"+ok", "+ok",
"*** End Patch", "*** End Patch",
].join("\\n"); ].join("\n");
const events = session.stream( const events = session.stream(
[ [
"Use the apply_patch tool and nothing else.", "Use the apply_patch tool and nothing else.",
@@ -621,7 +621,7 @@ describe("Codex app-server provider (integration)", () => {
"Apply the following patch exactly:", "Apply the following patch exactly:",
patch, patch,
"After approval, reply FILE_DONE.", "After approval, reply FILE_DONE.",
].join("\\n") ].join("\n")
); );
let failure: string | null = null; let failure: string | null = null;
@@ -18,7 +18,7 @@ import {
} from "./test-utils/dictation-e2e.js"; } from "./test-utils/dictation-e2e.js";
function tmpCwd(): string { function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-client-v2-")); return mkdtempSync(path.join(tmpdir(), "daemon-client-"));
} }
function waitForSignal<T>( function waitForSignal<T>(
@@ -52,7 +52,7 @@ function waitForSignal<T>(
}); });
} }
describe("daemon client v2 E2E", () => { describe("daemon client E2E", () => {
let ctx: DaemonTestContext; let ctx: DaemonTestContext;
beforeAll(async () => { beforeAll(async () => {
@@ -10,7 +10,7 @@
*/ */
import { WebSocket } from "ws"; import { WebSocket } from "ws";
import { DaemonClientV2 } from "../../client/daemon-client-v2.js"; import { DaemonClient } from "../../client/daemon-client.js";
// Patch WebSocket to log all messages // Patch WebSocket to log all messages
const OriginalWebSocket = WebSocket; const OriginalWebSocket = WebSocket;
@@ -36,7 +36,7 @@ async function testMultiAgentSequence() {
console.log("\n=== Testing multi-agent checkout sequence ==="); console.log("\n=== Testing multi-agent checkout sequence ===");
console.log(`Daemon URL: ${DAEMON_URL}`); console.log(`Daemon URL: ${DAEMON_URL}`);
const client = new DaemonClientV2({ const client = new DaemonClient({
url: DAEMON_URL, url: DAEMON_URL,
webSocketFactory: (url) => new LoggingWebSocket(url) as any, webSocketFactory: (url) => new LoggingWebSocket(url) as any,
reconnect: { enabled: false }, reconnect: { enabled: false },
+1 -1
View File
@@ -4,7 +4,7 @@ export { loadConfig, type CliConfigOverrides } from "./config.js";
export { resolvePaseoHome } from "./paseo-home.js"; export { resolvePaseoHome } from "./paseo-home.js";
export { createRootLogger, type LogLevel, type LogFormat } from "./logger.js"; export { createRootLogger, type LogLevel, type LogFormat } from "./logger.js";
export { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js"; export { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js";
export { DaemonClientV2, type DaemonClientV2Config, type ConnectionState, type DaemonEvent } from "../client/daemon-client-v2.js"; export { DaemonClient, type DaemonClientConfig, type ConnectionState, type DaemonEvent } from "../client/daemon-client.js";
// Agent SDK types for CLI commands // Agent SDK types for CLI commands
export type { export type {
+65 -5
View File
@@ -1023,17 +1023,36 @@ export class Session {
break; break;
} }
} catch (error: any) { } catch (error: any) {
const err = error instanceof Error ? error : new Error(String(error));
this.sessionLogger.error( this.sessionLogger.error(
{ err: error }, { err },
"Error handling message" "Error handling message"
); );
const requestId = (msg as { requestId?: unknown }).requestId;
if (typeof requestId === "string") {
try {
this.emit({
type: "rpc_error",
payload: {
requestId,
requestType: msg.type,
error: "Request failed",
code: "handler_error",
},
});
} catch (emitError) {
this.sessionLogger.error({ err: emitError }, "Failed to emit rpc_error");
}
}
this.emit({ this.emit({
type: "activity_log", type: "activity_log",
payload: { payload: {
id: uuidv4(), id: uuidv4(),
timestamp: new Date(), timestamp: new Date(),
type: "error", type: "error",
content: `Error: ${error.message}`, content: `Error: ${err.message}`,
}, },
}); });
} }
@@ -1976,7 +1995,11 @@ export class Session {
} }
private async generateCommitMessage(cwd: string): Promise<string> { private async generateCommitMessage(cwd: string): Promise<string> {
const diff = await getCheckoutDiff(cwd, { mode: "uncommitted" }, { paseoHome: this.paseoHome }); const diff = await getCheckoutDiff(
cwd,
{ mode: "uncommitted", includeStructured: true },
{ paseoHome: this.paseoHome }
);
const schema = z.object({ const schema = z.object({
message: z message: z
.string() .string()
@@ -1984,11 +2007,29 @@ export class Session {
.max(72) .max(72)
.describe("Concise git commit message, imperative mood, no trailing period."), .describe("Concise git commit message, imperative mood, no trailing period."),
}); });
const fileList =
diff.structured && diff.structured.length > 0
? [
"Files changed:",
...diff.structured.map((file) => {
const changeType = file.isNew ? "A" : file.isDeleted ? "D" : "M";
const status = file.status && file.status !== "ok" ? ` [${file.status}]` : "";
return `${changeType}\t${file.path}\t(+${file.additions} -${file.deletions})${status}`;
}),
].join("\n")
: "Files changed: (unknown)";
const maxPatchChars = 120_000;
const patch =
diff.diff.length > maxPatchChars
? `${diff.diff.slice(0, maxPatchChars)}\n\n... (diff truncated to ${maxPatchChars} chars)\n`
: diff.diff;
const prompt = [ const prompt = [
"Write a concise git commit message for the changes below.", "Write a concise git commit message for the changes below.",
"Return JSON only with a single field 'message'.", "Return JSON only with a single field 'message'.",
"", "",
diff.diff.length > 0 ? diff.diff : "(No diff available)", fileList,
"",
patch.length > 0 ? patch : "(No diff available)",
].join("\n"); ].join("\n");
try { try {
const result = await generateStructuredAgentResponse({ const result = await generateStructuredAgentResponse({
@@ -2023,6 +2064,7 @@ export class Session {
{ {
mode: "base", mode: "base",
baseRef, baseRef,
includeStructured: true,
}, },
{ paseoHome: this.paseoHome } { paseoHome: this.paseoHome }
); );
@@ -2030,11 +2072,29 @@ export class Session {
title: z.string().min(1).max(72), title: z.string().min(1).max(72),
body: z.string().min(1), body: z.string().min(1),
}); });
const fileList =
diff.structured && diff.structured.length > 0
? [
"Files changed:",
...diff.structured.map((file) => {
const changeType = file.isNew ? "A" : file.isDeleted ? "D" : "M";
const status = file.status && file.status !== "ok" ? ` [${file.status}]` : "";
return `${changeType}\t${file.path}\t(+${file.additions} -${file.deletions})${status}`;
}),
].join("\n")
: "Files changed: (unknown)";
const maxPatchChars = 200_000;
const patch =
diff.diff.length > maxPatchChars
? `${diff.diff.slice(0, maxPatchChars)}\n\n... (diff truncated to ${maxPatchChars} chars)\n`
: diff.diff;
const prompt = [ const prompt = [
"Write a pull request title and body for the changes below.", "Write a pull request title and body for the changes below.",
"Return JSON only with fields 'title' and 'body'.", "Return JSON only with fields 'title' and 'body'.",
"", "",
diff.diff.length > 0 ? diff.diff : "(No diff available)", fileList,
"",
patch.length > 0 ? patch : "(No diff available)",
].join("\n"); ].join("\n");
try { try {
return await generateStructuredAgentResponse({ return await generateStructuredAgentResponse({
@@ -1,13 +1,13 @@
import WebSocket from "ws"; import WebSocket from "ws";
import { import {
DaemonClientV2 as SharedDaemonClient, DaemonClient as SharedDaemonClient,
type DaemonClientV2Config as SharedDaemonClientConfig, type DaemonClientConfig as SharedDaemonClientConfig,
type CreateAgentRequestOptions, type CreateAgentRequestOptions,
type DaemonEvent, type DaemonEvent,
type DaemonEventHandler, type DaemonEventHandler,
type SendMessageOptions, type SendMessageOptions,
type WebSocketLike, type WebSocketLike,
} from "../../client/daemon-client-v2.js"; } from "../../client/daemon-client.js";
export type DaemonClientConfig = Omit< export type DaemonClientConfig = Omit<
SharedDaemonClientConfig, SharedDaemonClientConfig,
@@ -1,4 +1,4 @@
import type { DaemonClientV2 } from "../../client/daemon-client-v2.js"; import type { DaemonClient } from "../../client/daemon-client.js";
import type { SessionOutboundMessage } from "../../shared/messages.js"; import type { SessionOutboundMessage } from "../../shared/messages.js";
export interface MessageCollector { export interface MessageCollector {
@@ -7,7 +7,7 @@ export interface MessageCollector {
unsubscribe: () => void; unsubscribe: () => void;
} }
export function createMessageCollector(client: DaemonClientV2): MessageCollector { export function createMessageCollector(client: DaemonClient): MessageCollector {
const messages: SessionOutboundMessage[] = []; const messages: SessionOutboundMessage[] = [];
const unsubscribe = client.subscribeRawMessages((message) => { const unsubscribe = client.subscribeRawMessages((message) => {
messages.push(message); messages.push(message);
@@ -20,4 +20,3 @@ export function createMessageCollector(client: DaemonClientV2): MessageCollector
unsubscribe, unsubscribe,
}; };
} }
@@ -157,7 +157,56 @@ export class WebSocketSessionBridge {
private async handleRawMessage(ws: WebSocket, data: Buffer | ArrayBuffer | Buffer[]): Promise<void> { private async handleRawMessage(ws: WebSocket, data: Buffer | ArrayBuffer | Buffer[]): Promise<void> {
try { try {
const parsed = JSON.parse(data.toString()); const parsed = JSON.parse(data.toString());
const message = WSInboundMessageSchema.parse(parsed); const parsedMessage = WSInboundMessageSchema.safeParse(parsed);
if (!parsedMessage.success) {
const requestInfo = extractRequestInfoFromUnknownWsInbound(parsed);
const isUnknownSchema =
requestInfo?.requestId != null &&
typeof parsed === "object" &&
parsed != null &&
"type" in parsed &&
(parsed as { type?: unknown }).type === "session";
this.logger.warn(
{
requestId: requestInfo?.requestId,
requestType: requestInfo?.requestType,
error: parsedMessage.error.message,
},
"WS inbound message validation failed"
);
if (requestInfo) {
this.sendToClient(
ws,
wrapSessionMessage({
type: "rpc_error",
payload: {
requestId: requestInfo.requestId,
requestType: requestInfo.requestType,
error: isUnknownSchema ? "Unknown request schema" : "Invalid message",
code: isUnknownSchema ? "unknown_schema" : "invalid_message",
},
})
);
return;
}
const errorMessage = `Invalid message: ${parsedMessage.error.message}`;
this.sendToClient(
ws,
wrapSessionMessage({
type: "status",
payload: {
status: "error",
message: errorMessage,
},
})
);
return;
}
const message = parsedMessage.data;
const messageSummary = { const messageSummary = {
type: message.type, type: message.type,
@@ -234,6 +283,23 @@ export class WebSocketSessionBridge {
"Failed to parse/handle message" "Failed to parse/handle message"
); );
const requestInfo = extractRequestInfoFromUnknownWsInbound(parsedPayload);
if (requestInfo) {
this.sendToClient(
ws,
wrapSessionMessage({
type: "rpc_error",
payload: {
requestId: requestInfo.requestId,
requestType: requestInfo.requestType,
error: "Invalid message",
code: "invalid_message",
},
})
);
return;
}
this.sendToClient( this.sendToClient(
ws, ws,
wrapSessionMessage({ wrapSessionMessage({
@@ -466,3 +532,38 @@ export class WebSocketSessionBridge {
} }
} }
} }
function extractRequestInfoFromUnknownWsInbound(
payload: unknown
): { requestId: string; requestType?: string } | null {
if (!payload || typeof payload !== "object") {
return null;
}
const record = payload as {
type?: unknown;
requestId?: unknown;
message?: unknown;
};
// Session-wrapped messages
if (record.type === "session" && record.message && typeof record.message === "object") {
const msg = record.message as { requestId?: unknown; type?: unknown };
if (typeof msg.requestId === "string") {
return {
requestId: msg.requestId,
...(typeof msg.type === "string" ? { requestType: msg.type } : {}),
};
}
}
// Non-session messages (future-proof)
if (typeof record.requestId === "string") {
return {
requestId: record.requestId,
...(typeof record.type === "string" ? { requestType: record.type } : {}),
};
}
return null;
}
+12
View File
@@ -1024,6 +1024,16 @@ export const StatusMessageSchema = z.object({
.passthrough(), // Allow additional fields .passthrough(), // Allow additional fields
}); });
export const RpcErrorMessageSchema = z.object({
type: z.literal("rpc_error"),
payload: z.object({
requestId: z.string(),
requestType: z.string().optional(),
error: z.string(),
code: z.string().optional(),
}),
});
const AgentStatusWithRequestSchema = z.object({ const AgentStatusWithRequestSchema = z.object({
agentId: z.string(), agentId: z.string(),
requestId: z.string(), requestId: z.string(),
@@ -1607,6 +1617,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
DictationStreamFinalMessageSchema, DictationStreamFinalMessageSchema,
DictationStreamErrorMessageSchema, DictationStreamErrorMessageSchema,
StatusMessageSchema, StatusMessageSchema,
RpcErrorMessageSchema,
InitializeAgentResponseMessageSchema, InitializeAgentResponseMessageSchema,
ArtifactMessageSchema, ArtifactMessageSchema,
VoiceConversationLoadedMessageSchema, VoiceConversationLoadedMessageSchema,
@@ -1663,6 +1674,7 @@ export type AssistantChunkMessage = z.infer<typeof AssistantChunkMessageSchema>;
export type AudioOutputMessage = z.infer<typeof AudioOutputMessageSchema>; export type AudioOutputMessage = z.infer<typeof AudioOutputMessageSchema>;
export type TranscriptionResultMessage = z.infer<typeof TranscriptionResultMessageSchema>; export type TranscriptionResultMessage = z.infer<typeof TranscriptionResultMessageSchema>;
export type StatusMessage = z.infer<typeof StatusMessageSchema>; export type StatusMessage = z.infer<typeof StatusMessageSchema>;
export type RpcErrorMessage = z.infer<typeof RpcErrorMessageSchema>;
export type ArtifactMessage = z.infer<typeof ArtifactMessageSchema>; export type ArtifactMessage = z.infer<typeof ArtifactMessageSchema>;
export type VoiceConversationLoadedMessage = z.infer< export type VoiceConversationLoadedMessage = z.infer<
typeof VoiceConversationLoadedMessageSchema typeof VoiceConversationLoadedMessageSchema
@@ -102,6 +102,36 @@ describe("checkout git utilities", () => {
expect(logMessage).toBe(message); expect(logMessage).toBe(message);
}); });
it("diffs base mode against merge-base (no base-only deletions)", async () => {
execSync("git checkout -b feature", { cwd: repoDir });
// Advance base branch after feature splits off.
execSync("git checkout main", { cwd: repoDir });
writeFileSync(join(repoDir, "base-only.txt"), "base\n");
execSync("git add base-only.txt", { cwd: repoDir });
execSync("git -c commit.gpgsign=false commit -m 'base only'", { cwd: repoDir });
// Make a feature change.
execSync("git checkout feature", { cwd: repoDir });
writeFileSync(join(repoDir, "feature.txt"), "feature\n");
execSync("git add feature.txt", { cwd: repoDir });
execSync("git -c commit.gpgsign=false commit -m 'feature commit'", { cwd: repoDir });
const diff = await getCheckoutDiff(repoDir, { mode: "base", baseRef: "main" });
expect(diff.diff).toContain("feature.txt");
expect(diff.diff).not.toContain("base-only.txt");
});
it("does not throw on large diffs (marks file as too_large)", async () => {
const large = Array.from({ length: 200_000 }, (_, i) => `line ${i}`).join("\n") + "\n";
writeFileSync(join(repoDir, "file.txt"), large);
const diff = await getCheckoutDiff(repoDir, { mode: "uncommitted", includeStructured: true });
expect(diff.structured?.some((f) => f.path === "file.txt" && f.status === "too_large")).toBe(
true
);
});
it("handles status/diff/commit in a .paseo worktree", async () => { it("handles status/diff/commit in a .paseo worktree", async () => {
const result = await createWorktree({ const result = await createWorktree({
branchName: "main", branchName: "main",
+332 -50
View File
@@ -1,4 +1,4 @@
import { exec, execFile } from "child_process"; import { exec, execFile, spawn } from "child_process";
import { promisify } from "util"; import { promisify } from "util";
import { resolve, dirname, basename } from "path"; import { resolve, dirname, basename } from "path";
import { realpathSync } from "fs"; import { realpathSync } from "fs";
@@ -14,6 +14,203 @@ const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
GIT_OPTIONAL_LOCKS: "0", GIT_OPTIONAL_LOCKS: "0",
}; };
const SMALL_OUTPUT_MAX_BUFFER = 20 * 1024 * 1024; // 20MB
async function execGit(command: string, options: { cwd: string; env?: NodeJS.ProcessEnv }): Promise<{ stdout: string; stderr: string }> {
return execAsync(command, { ...options, maxBuffer: SMALL_OUTPUT_MAX_BUFFER });
}
async function execGitFile(
args: string[],
options: { cwd: string; env?: NodeJS.ProcessEnv }
): Promise<{ stdout: string; stderr: string }> {
return execFileAsync("git", args, { ...options, maxBuffer: SMALL_OUTPUT_MAX_BUFFER });
}
type LimitedTextResult = {
text: string;
truncated: boolean;
exitCode: number | null;
signal: NodeJS.Signals | null;
};
async function spawnLimitedText(params: {
cmd: string;
args: string[];
cwd: string;
env?: NodeJS.ProcessEnv;
maxBytes: number;
acceptExitCodes?: number[];
}): Promise<LimitedTextResult> {
const accept = new Set(params.acceptExitCodes ?? [0]);
return new Promise((resolvePromise, rejectPromise) => {
const child = spawn(params.cmd, params.args, {
cwd: params.cwd,
env: params.env,
stdio: ["ignore", "pipe", "pipe"],
});
const stdoutChunks: Buffer[] = [];
let stdoutBytes = 0;
let truncated = false;
const stop = () => {
if (child.killed) return;
try {
child.kill("SIGKILL");
} catch {
// ignore
}
};
child.stdout.on("data", (chunk: Buffer) => {
if (truncated) return;
stdoutBytes += chunk.length;
if (stdoutBytes > params.maxBytes) {
truncated = true;
stop();
return;
}
stdoutChunks.push(chunk);
});
// We don't buffer stderr (it can be large too). Keep it minimal for debugging.
let stderrPreview = "";
child.stderr.on("data", (chunk: Buffer) => {
if (stderrPreview.length > 2048) return;
stderrPreview += chunk.toString("utf8");
});
child.on("error", (error) => {
rejectPromise(error);
});
child.on("close", (code, signal) => {
if (code !== null && !accept.has(code) && !truncated) {
rejectPromise(new Error(`Command failed: ${params.cmd} ${params.args.join(" ")} (code ${code})\n${stderrPreview}`));
return;
}
resolvePromise({
text: Buffer.concat(stdoutChunks).toString("utf8"),
truncated,
exitCode: code,
signal,
});
});
});
}
type CheckoutFileChange = {
path: string;
oldPath?: string;
status: string;
isNew: boolean;
isDeleted: boolean;
isUntracked?: boolean;
};
async function listCheckoutFileChanges(cwd: string, ref: string): Promise<CheckoutFileChange[]> {
const changes: CheckoutFileChange[] = [];
const { stdout: nameStatusOut } = await execGit(`git diff --name-status ${ref}`, {
cwd,
env: READ_ONLY_GIT_ENV,
});
for (const line of nameStatusOut.split("\n").map((l) => l.trim()).filter(Boolean)) {
// `--name-status` uses TAB separators, which preserves filenames with spaces.
const tabParts = line.split("\t");
const rawStatus = (tabParts[0] ?? "").trim();
if (!rawStatus) continue;
if (rawStatus.startsWith("R") || rawStatus.startsWith("C")) {
const oldPath = tabParts[1];
const newPath = tabParts[2];
if (newPath) {
changes.push({
path: newPath,
...(oldPath ? { oldPath } : {}),
status: rawStatus,
isNew: false,
isDeleted: false,
});
}
continue;
}
const path = tabParts[1];
if (!path) continue;
const code = rawStatus[0];
changes.push({
path,
status: rawStatus,
isNew: code === "A",
isDeleted: code === "D",
});
}
const { stdout: untrackedOut } = await execGit("git ls-files --others --exclude-standard", {
cwd,
env: READ_ONLY_GIT_ENV,
});
for (const file of untrackedOut.split("\n").map((l) => l.trim()).filter(Boolean)) {
changes.push({
path: file,
status: "U",
isNew: true,
isDeleted: false,
isUntracked: true,
});
}
// Deduplicate by path (prefer tracked status over untracked marker if both appear).
const byPath = new Map<string, CheckoutFileChange>();
for (const change of changes) {
const existing = byPath.get(change.path);
if (!existing) {
byPath.set(change.path, change);
continue;
}
if (existing.isUntracked && !change.isUntracked) {
byPath.set(change.path, change);
}
}
return Array.from(byPath.values());
}
async function tryResolveMergeBase(cwd: string, baseRef: string): Promise<string | null> {
try {
const { stdout } = await execGit(`git merge-base ${baseRef} HEAD`, { cwd, env: READ_ONLY_GIT_ENV });
const sha = stdout.trim();
return sha.length > 0 ? sha : null;
} catch {
return null;
}
}
type FileStat = { additions: number; deletions: number; isBinary: boolean } | null;
async function tryGetNumstat(cwd: string, args: string[]): Promise<FileStat> {
try {
const { stdout } = await execGitFile(args, { cwd, env: READ_ONLY_GIT_ENV });
const line = stdout.trim().split("\n").map((l) => l.trim()).filter(Boolean)[0] ?? "";
if (!line) return null;
const [aRaw, dRaw] = line.split(/\s+/);
if (!aRaw || !dRaw) return null;
if (aRaw === "-" || dRaw === "-") {
return { additions: 0, deletions: 0, isBinary: true };
}
const additions = Number.parseInt(aRaw, 10);
const deletions = Number.parseInt(dRaw, 10);
if (Number.isNaN(additions) || Number.isNaN(deletions)) {
return null;
}
return { additions, deletions, isBinary: false };
} catch {
return null;
}
}
export class NotGitRepoError extends Error { export class NotGitRepoError extends Error {
readonly cwd: string; readonly cwd: string;
readonly code = "NOT_GIT_REPO"; readonly code = "NOT_GIT_REPO";
@@ -435,32 +632,58 @@ async function getAheadOfOrigin(cwd: string, currentBranch: string): Promise<num
} }
} }
async function getUntrackedDiff(cwd: string): Promise<string> { const PER_FILE_DIFF_MAX_BYTES = 1024 * 1024; // 1MB
let untrackedDiff = ""; const TOTAL_DIFF_MAX_BYTES = 2 * 1024 * 1024; // 2MB
try {
const { stdout: untrackedFiles } = await execAsync(
"git ls-files --others --exclude-standard",
{ cwd, env: READ_ONLY_GIT_ENV }
);
const newFiles = untrackedFiles.trim().split("\n").filter(Boolean);
for (const file of newFiles) { function buildPlaceholderParsedDiffFile(
try { change: CheckoutFileChange,
const { stdout: fileDiff } = await execAsync( options: { status: "too_large" | "binary"; stat?: FileStat }
`git diff --no-index /dev/null "${file}" || true`, ): ParsedDiffFile {
{ cwd, env: READ_ONLY_GIT_ENV } return {
); path: change.path,
if (fileDiff) { isNew: change.isNew,
untrackedDiff += fileDiff; isDeleted: change.isDeleted,
additions: options.stat?.additions ?? 0,
deletions: options.stat?.deletions ?? 0,
hunks: [],
status: options.status,
};
} }
} catch {
// Ignore errors for individual files async function getPerFileDiffText(
cwd: string,
ref: string,
change: CheckoutFileChange
): Promise<{ text: string; truncated: boolean; stat: FileStat }> {
const stat: FileStat =
change.isUntracked
? null
: await tryGetNumstat(cwd, ["diff", "--numstat", ref, "--", change.path]);
if (stat?.isBinary) {
return { text: "", truncated: false, stat };
} }
if (change.isUntracked) {
const result = await spawnLimitedText({
cmd: "git",
args: ["diff", "--no-index", "/dev/null", "--", change.path],
cwd,
env: READ_ONLY_GIT_ENV,
maxBytes: PER_FILE_DIFF_MAX_BYTES,
acceptExitCodes: [0, 1],
});
return { text: result.text, truncated: result.truncated, stat };
} }
} catch {
// Ignore errors getting untracked files const result = await spawnLimitedText({
} cmd: "git",
return untrackedDiff; args: ["diff", ref, "--", change.path],
cwd,
env: READ_ONLY_GIT_ENV,
maxBytes: PER_FILE_DIFF_MAX_BYTES,
});
return { text: result.text, truncated: result.truncated, stat };
} }
export async function getCheckoutStatus( export async function getCheckoutStatus(
@@ -530,43 +753,102 @@ export async function getCheckoutDiff(
): Promise<CheckoutDiffResult> { ): Promise<CheckoutDiffResult> {
await requireGitRepo(cwd); await requireGitRepo(cwd);
let diff = ""; let refForDiff: string;
if (compare.mode === "uncommitted") { if (compare.mode === "uncommitted") {
const { stdout: trackedDiff } = await execAsync("git diff HEAD", { refForDiff = "HEAD";
cwd,
env: READ_ONLY_GIT_ENV,
});
const untrackedDiff = await getUntrackedDiff(cwd);
diff = trackedDiff + untrackedDiff;
} else { } else {
const configured = await getConfiguredBaseRefForCwd(cwd, context); const configured = await getConfiguredBaseRefForCwd(cwd, context);
const baseRef = configured.baseRef ?? compare.baseRef ?? (await resolveBaseRef(cwd)); const baseRef = configured.baseRef ?? compare.baseRef ?? (await resolveBaseRef(cwd));
if (!baseRef) { if (!baseRef) {
diff = ""; return { diff: "" };
} else if (configured.isPaseoOwnedWorktree && compare.baseRef && compare.baseRef !== baseRef) {
throw new Error(`Base ref mismatch: expected ${baseRef}, got ${compare.baseRef}`);
} else {
const normalizedBaseRef = normalizeLocalBranchRefName(baseRef);
// Find the merge-base (common ancestor) to diff only changes on this branch
const { stdout: mergeBaseOut } = await execAsync(
`git merge-base ${normalizedBaseRef} HEAD`,
{ cwd, env: READ_ONLY_GIT_ENV }
);
const mergeBase = mergeBaseOut.trim();
// Diff from merge-base to working tree (includes uncommitted changes)
const { stdout: trackedDiff } = await execAsync(`git diff ${mergeBase}`, {
cwd,
env: READ_ONLY_GIT_ENV,
});
const untrackedDiff = await getUntrackedDiff(cwd);
diff = trackedDiff + untrackedDiff;
} }
if (configured.isPaseoOwnedWorktree && compare.baseRef && compare.baseRef !== baseRef) {
throw new Error(`Base ref mismatch: expected ${baseRef}, got ${compare.baseRef}`);
}
const normalizedBaseRef = normalizeLocalBranchRefName(baseRef);
const bestBaseRef = await resolveBestBaseRefForMerge(cwd, normalizedBaseRef);
refForDiff = (await tryResolveMergeBase(cwd, bestBaseRef)) ?? bestBaseRef;
}
const changes = await listCheckoutFileChanges(cwd, refForDiff);
changes.sort((a, b) => a.path.localeCompare(b.path));
const structured: ParsedDiffFile[] = [];
let diffText = "";
let diffBytes = 0;
const appendDiff = (text: string) => {
if (!text) return;
if (diffBytes >= TOTAL_DIFF_MAX_BYTES) return;
const buf = Buffer.from(text, "utf8");
if (diffBytes + buf.length <= TOTAL_DIFF_MAX_BYTES) {
diffText += text;
diffBytes += buf.length;
return;
}
const remaining = TOTAL_DIFF_MAX_BYTES - diffBytes;
if (remaining > 0) {
diffText += buf.subarray(0, remaining).toString("utf8");
diffBytes = TOTAL_DIFF_MAX_BYTES;
}
};
for (const change of changes) {
const { text, truncated, stat } = await getPerFileDiffText(cwd, refForDiff, change);
if (!compare.includeStructured) {
if (stat?.isBinary) {
appendDiff(`# ${change.path}: binary diff omitted\n`);
} else if (truncated) {
appendDiff(`# ${change.path}: diff too large omitted\n`);
} else {
appendDiff(text);
}
if (diffBytes >= TOTAL_DIFF_MAX_BYTES) {
break;
}
continue;
}
if (stat?.isBinary) {
structured.push(buildPlaceholderParsedDiffFile(change, { status: "binary", stat }));
appendDiff(`# ${change.path}: binary diff omitted\n`);
continue;
}
if (truncated) {
structured.push(buildPlaceholderParsedDiffFile(change, { status: "too_large", stat }));
appendDiff(`# ${change.path}: diff too large omitted\n`);
continue;
}
appendDiff(text);
const parsed = await parseAndHighlightDiff(text, cwd);
const parsedFile =
parsed[0] ??
({
path: change.path,
isNew: change.isNew,
isDeleted: change.isDeleted,
additions: stat?.additions ?? 0,
deletions: stat?.deletions ?? 0,
hunks: [],
} satisfies ParsedDiffFile);
structured.push({
...parsedFile,
path: change.path,
isNew: change.isNew,
isDeleted: change.isDeleted,
status: "ok",
});
} }
if (compare.includeStructured) { if (compare.includeStructured) {
return { diff, structured: await parseAndHighlightDiff(diff, cwd) }; return { diff: diffText, structured };
} }
return { diff }; return { diff: diffText };
} }
export async function commitChanges( export async function commitChanges(