Add Codex plan mode draft features

This commit is contained in:
Mohamed Boudra
2026-04-04 16:35:31 +07:00
parent a6f6c169c8
commit 7e99529bde
30 changed files with 1239 additions and 460 deletions
@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
getFeatureHighlightColor,
getFeatureTooltip,
getStatusSelectorHint, getStatusSelectorHint,
normalizeModelId, normalizeModelId,
resolveAgentModelSelection, resolveAgentModelSelection,
@@ -13,6 +15,31 @@ describe("getStatusSelectorHint", () => {
}); });
}); });
describe("feature metadata helpers", () => {
it("prefers explicit feature tooltip copy", () => {
expect(
getFeatureTooltip({
label: "Plan",
tooltip: "Toggle plan mode",
}),
).toBe("Toggle plan mode");
});
it("falls back to the feature label when no tooltip is provided", () => {
expect(
getFeatureTooltip({
label: "Custom",
}),
).toBe("Custom");
});
it("maps feature highlight colors by feature id", () => {
expect(getFeatureHighlightColor("fast_mode")).toBe("yellow");
expect(getFeatureHighlightColor("plan_mode")).toBe("blue");
expect(getFeatureHighlightColor("other")).toBe("default");
});
});
describe("normalizeModelId", () => { describe("normalizeModelId", () => {
it("treats empty values as unset", () => { it("treats empty values as unset", () => {
expect(normalizeModelId("")).toBeNull(); expect(normalizeModelId("")).toBeNull();
+226 -182
View File
@@ -3,7 +3,16 @@ import { View, Text, Platform, Pressable, Keyboard } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow"; import { useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional"; import { useStoreWithEqualityFn } from "zustand/traditional";
import { Brain, ChevronDown, Settings2, ShieldAlert, ShieldCheck, ShieldOff, Zap } from "lucide-react-native"; import {
Brain,
ChevronDown,
ListTodo,
Settings2,
ShieldAlert,
ShieldCheck,
ShieldOff,
Zap,
} from "lucide-react-native";
import { getProviderIcon } from "@/components/provider-icons"; import { getProviderIcon } from "@/components/provider-icons";
import { CombinedModelSelector } from "@/components/combined-model-selector"; import { CombinedModelSelector } from "@/components/combined-model-selector";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
@@ -37,6 +46,8 @@ import {
type AgentModeIcon, type AgentModeIcon,
} from "@server/server/agent/provider-manifest"; } from "@server/server/agent/provider-manifest";
import { import {
getFeatureHighlightColor,
getFeatureTooltip,
getStatusSelectorHint, getStatusSelectorHint,
resolveAgentModelSelection, resolveAgentModelSelection,
} from "@/components/agent-status-bar.utils"; } from "@/components/agent-status-bar.utils";
@@ -95,6 +106,8 @@ export interface DraftAgentStatusBarProps {
thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>; thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
selectedThinkingOptionId: string; selectedThinkingOptionId: string;
onSelectThinkingOption: (thinkingOptionId: string) => void; onSelectThinkingOption: (thinkingOptionId: string) => void;
features?: AgentFeature[];
onSetFeature?: (featureId: string, value: unknown) => void;
disabled?: boolean; disabled?: boolean;
} }
@@ -116,6 +129,7 @@ function findOptionLabel(
} }
const FEATURE_ICONS: Record<string, typeof Zap> = { const FEATURE_ICONS: Record<string, typeof Zap> = {
"list-todo": ListTodo,
zap: Zap, zap: Zap,
}; };
@@ -123,6 +137,29 @@ function getFeatureIcon(icon?: string) {
return (icon && FEATURE_ICONS[icon]) || Settings2; return (icon && FEATURE_ICONS[icon]) || Settings2;
} }
function getFeatureIconColor(
featureId: string,
enabled: boolean,
palette: {
blue: { 400: string };
yellow: { 400: string };
},
foregroundMuted: string,
): string {
if (!enabled) {
return foregroundMuted;
}
switch (getFeatureHighlightColor(featureId)) {
case "blue":
return palette.blue[400];
case "yellow":
return palette.yellow[400];
default:
return foregroundMuted;
}
}
const MODE_ICONS = { const MODE_ICONS = {
ShieldCheck, ShieldCheck,
ShieldAlert, ShieldAlert,
@@ -216,7 +253,8 @@ function ControlledStatusBar({
Boolean(providerOptions?.length) || Boolean(providerOptions?.length) ||
Boolean(modeOptions?.length) || Boolean(modeOptions?.length) ||
canSelectModel || canSelectModel ||
Boolean(thinkingOptions?.length); Boolean(thinkingOptions?.length) ||
Boolean(features?.length);
if (!hasAnyControl) { if (!hasAnyControl) {
return null; return null;
@@ -420,106 +458,6 @@ function ControlledStatusBar({
</> </>
) : null} ) : null}
{features?.map((feature) => {
if (feature.type === "toggle") {
const FeatureIcon = getFeatureIcon(feature.icon);
return (
<Tooltip
key={`feature-${feature.id}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
disabled={disabled}
onPress={() => onSetFeature?.(feature.id, !feature.value)}
style={({ pressed, hovered }) => [
styles.modeIconBadge,
hovered && styles.modeBadgeHovered,
pressed && styles.modeBadgePressed,
disabled && styles.disabledBadge,
]}
accessibilityRole="button"
accessibilityLabel={feature.label}
testID={`agent-feature-${feature.id}`}
>
<FeatureIcon
size={theme.iconSize.md}
color={
feature.value
? theme.colors.palette.yellow[400]
: theme.colors.foregroundMuted
}
/>
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{feature.label}</Text>
</TooltipContent>
</Tooltip>
);
}
if (feature.type === "select") {
const FeatureIcon = getFeatureIcon(feature.icon);
const selectedOption = feature.options.find((o) => o.id === feature.value);
return (
<DropdownMenu
key={`feature-${feature.id}`}
open={openSelector === `feature-${feature.id}`}
onOpenChange={(open) =>
setOpenSelector(open ? `feature-${feature.id}` : null)
}
>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="ref">
<DropdownMenuTrigger
disabled={disabled}
style={({ pressed, hovered }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === `feature-${feature.id}`) &&
styles.modeBadgePressed,
disabled && styles.disabledBadge,
]}
accessibilityRole="button"
accessibilityLabel={feature.label}
testID={`agent-feature-${feature.id}`}
>
<FeatureIcon
size={theme.iconSize.md}
color={theme.colors.foregroundMuted}
/>
<Text style={styles.modeBadgeText}>
{selectedOption?.label ?? feature.label}
</Text>
<ChevronDown
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{feature.label}</Text>
</TooltipContent>
</Tooltip>
<DropdownMenuContent side="top" align="start">
{feature.options.map((option) => (
<DropdownMenuItem
key={option.id}
selected={option.id === feature.value}
onSelect={() => onSetFeature?.(feature.id, option.id)}
>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
return null;
})}
{modeOptions && modeOptions.length > 0 ? ( {modeOptions && modeOptions.length > 0 ? (
<> <>
<Tooltip <Tooltip
@@ -568,6 +506,107 @@ function ControlledStatusBar({
/> />
</> </>
) : null} ) : null}
{features?.map((feature) => {
if (feature.type === "toggle") {
const FeatureIcon = getFeatureIcon(feature.icon);
return (
<Tooltip
key={`feature-${feature.id}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
disabled={disabled}
onPress={() => onSetFeature?.(feature.id, !feature.value)}
style={({ pressed, hovered }) => [
styles.modeIconBadge,
hovered && styles.modeBadgeHovered,
pressed && styles.modeBadgePressed,
disabled && styles.disabledBadge,
]}
accessibilityRole="button"
accessibilityLabel={getFeatureTooltip(feature)}
testID={`agent-feature-${feature.id}`}
>
<FeatureIcon
size={theme.iconSize.md}
color={getFeatureIconColor(
feature.id,
feature.value,
theme.colors.palette,
theme.colors.foregroundMuted,
)}
/>
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getFeatureTooltip(feature)}</Text>
</TooltipContent>
</Tooltip>
);
}
if (feature.type === "select") {
const FeatureIcon = getFeatureIcon(feature.icon);
const selectedOption = feature.options.find((o) => o.id === feature.value);
return (
<DropdownMenu
key={`feature-${feature.id}`}
open={openSelector === `feature-${feature.id}`}
onOpenChange={(open) =>
setOpenSelector(open ? `feature-${feature.id}` : null)
}
>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="ref">
<DropdownMenuTrigger
disabled={disabled}
style={({ pressed, hovered }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === `feature-${feature.id}`) &&
styles.modeBadgePressed,
disabled && styles.disabledBadge,
]}
accessibilityRole="button"
accessibilityLabel={getFeatureTooltip(feature)}
testID={`agent-feature-${feature.id}`}
>
<FeatureIcon
size={theme.iconSize.md}
color={theme.colors.foregroundMuted}
/>
<Text style={styles.modeBadgeText}>
{selectedOption?.label ?? feature.label}
</Text>
<ChevronDown
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getFeatureTooltip(feature)}</Text>
</TooltipContent>
</Tooltip>
<DropdownMenuContent side="top" align="start">
{feature.options.map((option) => (
<DropdownMenuItem
key={option.id}
selected={option.id === feature.value}
onSelect={() => onSetFeature?.(feature.id, option.id)}
>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
return null;
})}
</> </>
) : ( ) : (
<> <>
@@ -664,86 +703,6 @@ function ControlledStatusBar({
</View> </View>
) : null} ) : null}
{features?.map((feature) => {
if (feature.type === "toggle") {
const FeatureIcon = getFeatureIcon(feature.icon);
return (
<View key={`feature-${feature.id}`} style={styles.sheetSection}>
<Pressable
disabled={disabled}
onPress={() => onSetFeature?.(feature.id, !feature.value)}
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
disabled && styles.disabledSheetSelect,
]}
accessibilityRole="button"
accessibilityLabel={feature.label}
testID={`agent-feature-${feature.id}`}
>
<FeatureIcon
size={theme.iconSize.md}
color={
feature.value
? theme.colors.palette.yellow[400]
: theme.colors.foregroundMuted
}
/>
<Text style={styles.sheetSelectText}>{feature.label}</Text>
<Text style={styles.modeBadgeText}>
{feature.value ? "On" : "Off"}
</Text>
</Pressable>
</View>
);
}
if (feature.type === "select") {
const selectedOption = feature.options.find((o) => o.id === feature.value);
return (
<View key={`feature-${feature.id}`} style={styles.sheetSection}>
<DropdownMenu
open={openSelector === `feature-${feature.id}`}
onOpenChange={(open) =>
setOpenSelector(open ? `feature-${feature.id}` : null)
}
>
<DropdownMenuTrigger
disabled={disabled}
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
disabled && styles.disabledSheetSelect,
]}
accessibilityRole="button"
accessibilityLabel={feature.label}
testID={`agent-feature-${feature.id}`}
>
<Text style={styles.sheetSelectText}>
{selectedOption?.label ?? feature.label}
</Text>
<ChevronDown
size={theme.iconSize.md}
color={theme.colors.foregroundMuted}
/>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{feature.options.map((option) => (
<DropdownMenuItem
key={option.id}
selected={option.id === feature.value}
onSelect={() => onSetFeature?.(feature.id, option.id)}
>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
);
}
return null;
})}
{modeOptions && modeOptions.length > 0 ? ( {modeOptions && modeOptions.length > 0 ? (
<View style={styles.sheetSection}> <View style={styles.sheetSection}>
<DropdownMenu <DropdownMenu
@@ -786,6 +745,85 @@ function ControlledStatusBar({
</DropdownMenu> </DropdownMenu>
</View> </View>
) : null} ) : null}
{features?.map((feature) => {
if (feature.type === "toggle") {
const FeatureIcon = getFeatureIcon(feature.icon);
return (
<View key={`feature-${feature.id}`} style={styles.sheetSection}>
<Pressable
disabled={disabled}
onPress={() => onSetFeature?.(feature.id, !feature.value)}
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
disabled && styles.disabledSheetSelect,
]}
accessibilityRole="button"
accessibilityLabel={getFeatureTooltip(feature)}
testID={`agent-feature-${feature.id}`}
>
<FeatureIcon
size={theme.iconSize.md}
color={getFeatureIconColor(
feature.id,
feature.value,
theme.colors.palette,
theme.colors.foregroundMuted,
)}
/>
<Text style={styles.sheetSelectText}>{feature.label}</Text>
<Text style={styles.modeBadgeText}>{feature.value ? "On" : "Off"}</Text>
</Pressable>
</View>
);
}
if (feature.type === "select") {
const selectedOption = feature.options.find((o) => o.id === feature.value);
return (
<View key={`feature-${feature.id}`} style={styles.sheetSection}>
<DropdownMenu
open={openSelector === `feature-${feature.id}`}
onOpenChange={(open) =>
setOpenSelector(open ? `feature-${feature.id}` : null)
}
>
<DropdownMenuTrigger
disabled={disabled}
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
disabled && styles.disabledSheetSelect,
]}
accessibilityRole="button"
accessibilityLabel={getFeatureTooltip(feature)}
testID={`agent-feature-${feature.id}`}
>
<Text style={styles.sheetSelectText}>
{selectedOption?.label ?? feature.label}
</Text>
<ChevronDown
size={theme.iconSize.md}
color={theme.colors.foregroundMuted}
/>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{feature.options.map((option) => (
<DropdownMenuItem
key={option.id}
selected={option.id === feature.value}
onSelect={() => onSetFeature?.(feature.id, option.id)}
>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
);
}
return null;
})}
</AdaptiveModalSheet> </AdaptiveModalSheet>
</> </>
)} )}
@@ -1008,6 +1046,8 @@ export function DraftAgentStatusBar({
thinkingOptions, thinkingOptions,
selectedThinkingOptionId, selectedThinkingOptionId,
onSelectThinkingOption, onSelectThinkingOption,
features,
onSetFeature,
disabled = false, disabled = false,
}: DraftAgentStatusBarProps) { }: DraftAgentStatusBarProps) {
const isWeb = Platform.OS === "web"; const isWeb = Platform.OS === "web";
@@ -1061,6 +1101,8 @@ export function DraftAgentStatusBar({
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined} thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
selectedThinkingOptionId={effectiveSelectedThinkingOption} selectedThinkingOptionId={effectiveSelectedThinkingOption}
onSelectThinkingOption={onSelectThinkingOption} onSelectThinkingOption={onSelectThinkingOption}
features={features}
onSetFeature={onSetFeature}
disabled={disabled} disabled={disabled}
/> />
</View> </View>
@@ -1093,6 +1135,8 @@ export function DraftAgentStatusBar({
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined} thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
selectedThinkingOptionId={effectiveSelectedThinkingOption} selectedThinkingOptionId={effectiveSelectedThinkingOption}
onSelectThinkingOption={onSelectThinkingOption} onSelectThinkingOption={onSelectThinkingOption}
features={features}
onSetFeature={onSetFeature}
disabled={disabled} disabled={disabled}
/> />
); );
@@ -1,6 +1,7 @@
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types"; import type { AgentFeature, AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
export type ExplainedStatusSelector = "mode" | "model" | "thinking"; export type ExplainedStatusSelector = "mode" | "model" | "thinking";
export type FeatureHighlightColor = "blue" | "default" | "yellow";
export function getStatusSelectorHint(selector: ExplainedStatusSelector): string { export function getStatusSelectorHint(selector: ExplainedStatusSelector): string {
switch (selector) { switch (selector) {
@@ -21,6 +22,21 @@ export function normalizeModelId(modelId: string | null | undefined): string | n
return normalized; return normalized;
} }
export function getFeatureTooltip(feature: Pick<AgentFeature, "label" | "tooltip">): string {
return feature.tooltip ?? feature.label;
}
export function getFeatureHighlightColor(featureId: string): FeatureHighlightColor {
switch (featureId) {
case "fast_mode":
return "yellow";
case "plan_mode":
return "blue";
default:
return "default";
}
}
export function resolveAgentModelSelection(input: { export function resolveAgentModelSelection(input: {
models: AgentModelDefinition[] | null; models: AgentModelDefinition[] | null;
runtimeModelId: string | null | undefined; runtimeModelId: string | null | undefined;
+53 -136
View File
@@ -9,7 +9,6 @@ import {
useState, useState,
} from "react"; } from "react";
import { View, Text, Pressable, Platform, ActivityIndicator } from "react-native"; import { View, Text, Pressable, Platform, ActivityIndicator } from "react-native";
import Markdown from "react-native-markdown-display";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
@@ -36,6 +35,7 @@ import {
MessageOuterSpacingProvider, MessageOuterSpacingProvider,
type InlinePathTarget, type InlinePathTarget,
} from "./message"; } from "./message";
import { PlanCard } from "./plan-card";
import type { StreamItem } from "@/types/stream"; import type { StreamItem } from "@/types/stream";
import type { PendingPermission } from "@/types/shared"; import type { PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types"; import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
@@ -59,9 +59,7 @@ import {
type BottomAnchorLocalRequest, type BottomAnchorLocalRequest,
type BottomAnchorRouteRequest, type BottomAnchorRouteRequest,
} from "./use-bottom-anchor-controller"; } from "./use-bottom-anchor-controller";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout"; import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { getMarkdownListMarker } from "@/utils/markdown-list";
import { normalizeInlinePathTarget } from "@/utils/inline-path"; import { normalizeInlinePathTarget } from "@/utils/inline-path";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { useStableEvent } from "@/hooks/use-stable-event"; import { useStableEvent } from "@/hooks/use-stable-event";
@@ -252,10 +250,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) { if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
return looseGap; return looseGap;
} }
if ( if ((item.kind === "user_message" || item.kind === "assistant_message") && isToolSequenceItem(belowItem)) {
(item.kind === "user_message" || item.kind === "assistant_message") &&
isToolSequenceItem(belowItem)
) {
return tightGap; return tightGap;
} }
if (item.kind === "todo_list" && isToolSequenceItem(belowItem)) { if (item.kind === "todo_list" && isToolSequenceItem(belowItem)) {
@@ -368,7 +363,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
workspaceRoot={workspaceRoot} workspaceRoot={workspaceRoot}
/> />
); );
case "thought": { case "thought": {
const nextItem = getStreamNeighborItem({ const nextItem = getStreamNeighborItem({
strategy: streamRenderStrategy, strategy: streamRenderStrategy,
@@ -755,90 +749,6 @@ function PermissionRequestCard({
return undefined; return undefined;
}, [request]); }, [request]);
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
const markdownRules = useMemo(() => {
return {
text: (
node: any,
_children: React.ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.text]}>
{node.content}
</Text>
),
textgroup: (
node: any,
children: React.ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.textgroup]}>
{children}
</Text>
),
code_block: (
node: any,
_children: React.ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_block]}>
{node.content}
</Text>
),
fence: (
node: any,
_children: React.ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.fence]}>
{node.content}
</Text>
),
code_inline: (
node: any,
_children: React.ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_inline]}>
{node.content}
</Text>
),
bullet_list: (node: any, children: React.ReactNode[], _parent: any, styles: any) => (
<View key={node.key} style={styles.bullet_list}>
{children}
</View>
),
ordered_list: (node: any, children: React.ReactNode[], _parent: any, styles: any) => (
<View key={node.key} style={styles.ordered_list}>
{children}
</View>
),
list_item: (node: any, children: React.ReactNode[], parent: any, styles: any) => {
const { isOrdered, marker } = getMarkdownListMarker(node, parent);
const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon;
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
return (
<View key={node.key} style={[styles.list_item, { flexShrink: 0 }]}>
<Text style={iconStyle}>{marker}</Text>
<Text style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</Text>
</View>
);
},
};
}, []);
const permissionMutation = useMutation({ const permissionMutation = useMutation({
mutationFn: async (input: { mutationFn: async (input: {
agentId: string; agentId: string;
@@ -891,50 +801,8 @@ function PermissionRequestCard({
); );
} }
return ( const footer = (
<View <>
style={[
permissionStyles.container,
{
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
},
]}
>
<Text style={[permissionStyles.title, { color: theme.colors.foreground }]}>{title}</Text>
{description ? (
<Text style={[permissionStyles.description, { color: theme.colors.foregroundMuted }]}>
{description}
</Text>
) : null}
{planMarkdown ? (
<View style={permissionStyles.section}>
{!isPlanRequest ? (
<Text style={[permissionStyles.sectionTitle, { color: theme.colors.foregroundMuted }]}>
Proposed plan
</Text>
) : null}
<Markdown style={markdownStyles} rules={markdownRules}>
{planMarkdown}
</Markdown>
</View>
) : null}
{!isPlanRequest ? (
<ToolCallDetailsContent
detail={
request.detail ?? {
type: "unknown",
input: request.input ?? null,
output: null,
}
}
maxHeight={200}
/>
) : null}
<Text <Text
testID="permission-request-question" testID="permission-request-question"
style={[permissionStyles.question, { color: theme.colors.foregroundMuted }]} style={[permissionStyles.question, { color: theme.colors.foregroundMuted }]}
@@ -1007,6 +875,55 @@ function PermissionRequestCard({
)} )}
</Pressable> </Pressable>
</View> </View>
</>
);
if (isPlanRequest && planMarkdown) {
return (
<PlanCard
title={title}
description={description}
text={planMarkdown}
footer={footer}
disableOuterSpacing
/>
);
}
return (
<View
style={[
permissionStyles.container,
{
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
},
]}
>
<Text style={[permissionStyles.title, { color: theme.colors.foreground }]}>{title}</Text>
{description ? (
<Text style={[permissionStyles.description, { color: theme.colors.foregroundMuted }]}>
{description}
</Text>
) : null}
{planMarkdown ? <PlanCard title="Proposed plan" text={planMarkdown} disableOuterSpacing /> : null}
{!isPlanRequest ? (
<ToolCallDetailsContent
detail={
request.detail ?? {
type: "unknown",
input: request.input ?? null,
output: null,
}
}
maxHeight={200}
/>
) : null}
{footer}
</View> </View>
); );
} }
+7
View File
@@ -71,6 +71,7 @@ import { getMarkdownListMarker } from "@/utils/markdown-list";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { markScrollInvestigationEvent } from "@/utils/scroll-jank-investigation"; import { markScrollInvestigationEvent } from "@/utils/scroll-jank-investigation";
export type { InlinePathTarget } from "@/utils/inline-path"; export type { InlinePathTarget } from "@/utils/inline-path";
import { PlanCard } from "./plan-card";
import { useToolCallSheet } from "./tool-call-sheet"; import { useToolCallSheet } from "./tool-call-sheet";
import { ToolCallDetailsContent } from "./tool-call-details"; import { ToolCallDetailsContent } from "./tool-call-details";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
@@ -1884,6 +1885,12 @@ export const ToolCall = memo(function ToolCall({
); );
}, [isMobile, effectiveDetail, errorText, isLoadingDetails]); }, [isMobile, effectiveDetail, errorText, isLoadingDetails]);
if (effectiveDetail?.type === "plan") {
return (
<PlanCard title="Plan" text={effectiveDetail.text} disableOuterSpacing={disableOuterSpacing} />
);
}
return ( return (
<ExpandableBadge <ExpandableBadge
testID="tool-call-badge" testID="tool-call-badge"
+154
View File
@@ -0,0 +1,154 @@
import type { ReactNode } from "react";
import { Text, View } from "react-native";
import Markdown from "react-native-markdown-display";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { getMarkdownListMarker } from "@/utils/markdown-list";
function createPlanMarkdownRules() {
return {
text: (
node: any,
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.text]}>
{node.content}
</Text>
),
textgroup: (
node: any,
children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.textgroup]}>
{children}
</Text>
),
code_block: (
node: any,
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_block]}>
{node.content}
</Text>
),
fence: (
node: any,
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.fence]}>
{node.content}
</Text>
),
code_inline: (
node: any,
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_inline]}>
{node.content}
</Text>
),
bullet_list: (node: any, children: ReactNode[], _parent: any, styles: any) => (
<View key={node.key} style={styles.bullet_list}>
{children}
</View>
),
ordered_list: (node: any, children: ReactNode[], _parent: any, styles: any) => (
<View key={node.key} style={styles.ordered_list}>
{children}
</View>
),
list_item: (node: any, children: ReactNode[], parent: any, styles: any) => {
const { isOrdered, marker } = getMarkdownListMarker(node, parent);
const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon;
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
return (
<View key={node.key} style={[styles.list_item, { flexShrink: 0 }]}>
<Text style={iconStyle}>{marker}</Text>
<Text style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</Text>
</View>
);
},
};
}
export function PlanCard({
title = "Plan",
description,
text,
footer,
disableOuterSpacing = false,
}: {
title?: string;
description?: string;
text: string;
footer?: ReactNode;
disableOuterSpacing?: boolean;
}) {
const { theme } = useUnistyles();
const markdownStyles = createMarkdownStyles(theme);
const markdownRules = createPlanMarkdownRules();
return (
<View
style={[
styles.container,
disableOuterSpacing && styles.containerCompact,
{
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
},
]}
>
<Text style={[styles.title, { color: theme.colors.foreground }]}>{title}</Text>
{description ? (
<Text style={[styles.description, { color: theme.colors.foregroundMuted }]}>
{description}
</Text>
) : null}
<Markdown style={markdownStyles} rules={markdownRules}>
{text}
</Markdown>
{footer ? <View style={styles.footer}>{footer}</View> : null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
marginVertical: theme.spacing[3],
padding: theme.spacing[3],
borderRadius: theme.spacing[2],
borderWidth: 1,
gap: theme.spacing[2],
},
containerCompact: {
marginVertical: 0,
},
title: {
fontSize: theme.fontSize.base,
lineHeight: 22,
},
description: {
fontSize: theme.fontSize.sm,
lineHeight: 20,
},
footer: {
gap: theme.spacing[2],
},
}));
@@ -100,11 +100,16 @@ const DEFAULT_STATUS_DOT_SIZE = 7;
const EMPHASIZED_STATUS_DOT_SIZE = 9; const EMPHASIZED_STATUS_DOT_SIZE = 9;
const DEFAULT_STATUS_DOT_OFFSET = 0; const DEFAULT_STATUS_DOT_OFFSET = 0;
const EMPHASIZED_STATUS_DOT_OFFSET = -1; const EMPHASIZED_STATUS_DOT_OFFSET = -1;
const GITHUB_PR_STATE_LABELS: Record<PrHint["state"], string> = { function getWorkspacePrIconColor(theme: ReturnType<typeof useUnistyles>["theme"], state: PrHint["state"]) {
open: "Open", switch (state) {
merged: "Merged", case "merged":
closed: "Closed", return theme.colors.palette.purple[500];
}; case "open":
return theme.colors.palette.green[500];
case "closed":
return theme.colors.palette.red[500];
}
}
interface SidebarWorkspaceListProps { interface SidebarWorkspaceListProps {
projects: SidebarProjectEntry[]; projects: SidebarProjectEntry[];
@@ -169,7 +174,8 @@ interface WorkspaceRowInnerProps {
function WorkspacePrBadge({ hint }: { hint: PrHint }) { function WorkspacePrBadge({ hint }: { hint: PrHint }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const activeColor = isHovered ? theme.colors.foreground : theme.colors.foregroundMuted; const textColor = isHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
const iconColor = getWorkspacePrIconColor(theme, hint.state);
const handlePressIn = useCallback((event: GestureResponderEvent) => { const handlePressIn = useCallback((event: GestureResponderEvent) => {
event.stopPropagation(); event.stopPropagation();
@@ -197,17 +203,17 @@ function WorkspacePrBadge({ hint }: { hint: PrHint }) {
pressed && styles.workspacePrBadgePressed, pressed && styles.workspacePrBadgePressed,
]} ]}
> >
<GitPullRequest size={12} color={activeColor} /> <GitPullRequest size={12} color={iconColor} />
<Text <Text
style={[ style={[
styles.workspacePrBadgeText, styles.workspacePrBadgeText,
{ color: activeColor }, { color: textColor },
]} ]}
numberOfLines={1} numberOfLines={1}
> >
#{hint.number} · {GITHUB_PR_STATE_LABELS[hint.state]} #{hint.number}
</Text> </Text>
{isHovered && <ExternalLink size={10} color={activeColor} />} {isHovered && <ExternalLink size={10} color={textColor} />}
</Pressable> </Pressable>
); );
} }
@@ -62,12 +62,14 @@ function normalizeDraftCommandConfig(
const modeId = draftConfig.modeId?.trim() ?? ""; const modeId = draftConfig.modeId?.trim() ?? "";
const model = draftConfig.model?.trim() ?? ""; const model = draftConfig.model?.trim() ?? "";
const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? ""; const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? "";
const featureValues = draftConfig.featureValues;
return { return {
provider: draftConfig.provider, provider: draftConfig.provider,
cwd, cwd,
...(modeId ? { modeId } : {}), ...(modeId ? { modeId } : {}),
...(model ? { model } : {}), ...(model ? { model } : {}),
...(thinkingOptionId ? { thinkingOptionId } : {}), ...(thinkingOptionId ? { thinkingOptionId } : {}),
...(featureValues && Object.keys(featureValues).length > 0 ? { featureValues } : {}),
}; };
} }
@@ -16,6 +16,7 @@ export interface DraftCommandConfig {
modeId?: string; modeId?: string;
model?: string; model?: string;
thinkingOptionId?: string; thinkingOptionId?: string;
featureValues?: Record<string, unknown>;
} }
function commandsQueryKey(serverId: string, agentId: string, draftConfig?: DraftCommandConfig) { function commandsQueryKey(serverId: string, agentId: string, draftConfig?: DraftCommandConfig) {
@@ -28,6 +29,7 @@ function commandsQueryKey(serverId: string, agentId: string, draftConfig?: Draft
draftConfig?.modeId ?? null, draftConfig?.modeId ?? null,
draftConfig?.model ?? null, draftConfig?.model ?? null,
draftConfig?.thinkingOptionId ?? null, draftConfig?.thinkingOptionId ?? null,
draftConfig?.featureValues ?? null,
] as const; ] as const;
} }
@@ -0,0 +1,134 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type {
AgentFeature,
AgentProvider,
AgentSessionConfig,
} from "@server/server/agent/agent-sdk-types";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
function pruneFeatureValues(
featureValues: Record<string, unknown>,
features: AgentFeature[],
): Record<string, unknown> {
const allowedFeatureIds = new Set(features.map((feature) => feature.id));
let changed = false;
const next: Record<string, unknown> = {};
for (const [featureId, value] of Object.entries(featureValues)) {
if (!allowedFeatureIds.has(featureId)) {
changed = true;
continue;
}
next[featureId] = value;
}
return changed ? next : featureValues;
}
function applyFeatureValues(
features: AgentFeature[],
featureValues: Record<string, unknown>,
): AgentFeature[] {
if (Object.keys(featureValues).length === 0) {
return features;
}
return features.map((feature) => {
if (!Object.prototype.hasOwnProperty.call(featureValues, feature.id)) {
return feature;
}
return {
...feature,
value: featureValues[feature.id],
} as AgentFeature;
});
}
type DraftFeatureConfig = Pick<
AgentSessionConfig,
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId"
>;
export function useDraftAgentFeatures(input: {
serverId: string | null | undefined;
provider: AgentProvider;
cwd: string | null | undefined;
modeId: string | null | undefined;
modelId: string | null | undefined;
thinkingOptionId: string | null | undefined;
}) {
const { serverId, provider, cwd, modeId, modelId, thinkingOptionId } = input;
const [featureValues, setFeatureValues] = useState<Record<string, unknown>>({});
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const normalizedCwd = cwd?.trim() || "";
const draftConfig = useMemo<DraftFeatureConfig | null>(() => {
if (!normalizedCwd) {
return null;
}
return {
provider,
cwd: normalizedCwd,
...(modeId ? { modeId } : {}),
...(modelId ? { model: modelId } : {}),
...(thinkingOptionId ? { thinkingOptionId } : {}),
};
}, [modeId, modelId, normalizedCwd, provider, thinkingOptionId]);
const featuresQuery = useQuery({
queryKey: [
"providerFeatures",
serverId ?? null,
provider,
normalizedCwd || null,
modeId ?? null,
modelId ?? null,
thinkingOptionId ?? null,
],
enabled: Boolean(serverId && client && isConnected && draftConfig),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client || !draftConfig) {
throw new Error("Host is not connected");
}
const payload = await client.listProviderFeatures(draftConfig);
if (payload.error) {
throw new Error(payload.error);
}
return payload.features ?? [];
},
});
const features = useMemo(() => {
return applyFeatureValues(featuresQuery.data ?? [], featureValues);
}, [featureValues, featuresQuery.data]);
useEffect(() => {
const next = pruneFeatureValues(featureValues, features);
if (next !== featureValues) {
setFeatureValues(next);
}
}, [featureValues, features]);
const effectiveFeatureValues = Object.keys(featureValues).length > 0 ? featureValues : undefined;
const setFeatureValue = useCallback((featureId: string, value: unknown) => {
setFeatureValues((current) => {
if (Object.is(current[featureId], value)) {
return current;
}
return { ...current, [featureId]: value };
});
}, []);
return {
features,
featureValues: effectiveFeatureValues,
isLoading: featuresQuery.isLoading,
setFeatureValue,
};
}
@@ -56,6 +56,7 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow"; import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
const EMPTY_PENDING_PERMISSIONS = new Map(); const EMPTY_PENDING_PERMISSIONS = new Map();
const DRAFT_CAPABILITIES: AgentCapabilityFlags = { const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
@@ -757,6 +758,18 @@ function DraftAgentScreenContent({
availableModels.find((model) => model.id === effectiveDraftModelId) ?? null; availableModels.find((model) => model.id === effectiveDraftModelId) ?? null;
return selectedModelDefinition?.defaultThinkingOptionId ?? ""; return selectedModelDefinition?.defaultThinkingOptionId ?? "";
}, [availableModels, effectiveDraftModelId, selectedThinkingOptionId]); }, [availableModels, effectiveDraftModelId, selectedThinkingOptionId]);
const {
features: draftFeatures,
featureValues: draftFeatureValues,
setFeatureValue: setDraftFeatureValue,
} = useDraftAgentFeatures({
serverId: selectedServerId,
provider: selectedProvider,
cwd: workingDir,
modeId: selectedMode,
modelId: effectiveDraftModelId,
thinkingOptionId: effectiveDraftThinkingOptionId,
});
const draftCommandConfig = useMemo<DraftCommandConfig | undefined>(() => { const draftCommandConfig = useMemo<DraftCommandConfig | undefined>(() => {
const cwd = ( const cwd = (
isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir
@@ -773,8 +786,10 @@ function DraftAgentScreenContent({
...(effectiveDraftThinkingOptionId ...(effectiveDraftThinkingOptionId
? { thinkingOptionId: effectiveDraftThinkingOptionId } ? { thinkingOptionId: effectiveDraftThinkingOptionId }
: {}), : {}),
...(draftFeatureValues ? { featureValues: draftFeatureValues } : {}),
}; };
}, [ }, [
draftFeatureValues,
effectiveDraftModelId, effectiveDraftModelId,
effectiveDraftThinkingOptionId, effectiveDraftThinkingOptionId,
isAttachWorktree, isAttachWorktree,
@@ -878,6 +893,7 @@ function DraftAgentScreenContent({
title: "New agent", title: "New agent",
cwd, cwd,
model, model,
features: draftFeatures,
thinkingOptionId, thinkingOptionId,
labels: {}, labels: {},
}; };
@@ -896,6 +912,7 @@ function DraftAgentScreenContent({
...(effectiveDraftThinkingOptionId ...(effectiveDraftThinkingOptionId
? { thinkingOptionId: effectiveDraftThinkingOptionId } ? { thinkingOptionId: effectiveDraftThinkingOptionId }
: {}), : {}),
...(draftFeatureValues ? { featureValues: draftFeatureValues } : {}),
}; };
const effectiveBaseBranch = baseBranch.trim(); const effectiveBaseBranch = baseBranch.trim();
@@ -1248,6 +1265,8 @@ function DraftAgentScreenContent({
thinkingOptions: availableThinkingOptions, thinkingOptions: availableThinkingOptions,
selectedThinkingOptionId, selectedThinkingOptionId,
onSelectThinkingOption: setThinkingOptionFromUser, onSelectThinkingOption: setThinkingOptionFromUser,
features: draftFeatures,
onSetFeature: setDraftFeatureValue,
disabled: isSubmitting, disabled: isSubmitting,
}} }}
/> />
@@ -8,6 +8,7 @@ import type { ImageAttachment } from "@/components/message-input";
import { useAgentFormState } from "@/hooks/use-agent-form-state"; import { useAgentFormState } from "@/hooks/use-agent-form-state";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow"; import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { buildDraftStoreKey } from "@/stores/draft-keys"; import { buildDraftStoreKey } from "@/stores/draft-keys";
import type { Agent } from "@/stores/session-store"; import type { Agent } from "@/stores/session-store";
@@ -110,6 +111,18 @@ export function WorkspaceDraftAgentTab({
availableModels.find((model) => model.id === effectiveDraftModelId) ?? null; availableModels.find((model) => model.id === effectiveDraftModelId) ?? null;
return selectedModelDefinition?.defaultThinkingOptionId ?? ""; return selectedModelDefinition?.defaultThinkingOptionId ?? "";
}, [availableModels, effectiveDraftModelId, selectedThinkingOptionId]); }, [availableModels, effectiveDraftModelId, selectedThinkingOptionId]);
const {
features: draftFeatures,
featureValues: draftFeatureValues,
setFeatureValue: setDraftFeatureValue,
} = useDraftAgentFeatures({
serverId,
provider: selectedProvider,
cwd: workspaceId,
modeId: selectedMode,
modelId: effectiveDraftModelId,
thinkingOptionId: effectiveDraftThinkingOptionId,
});
const { const {
formErrorMessage, formErrorMessage,
@@ -168,6 +181,7 @@ export function WorkspaceDraftAgentTab({
title: "Agent", title: "Agent",
cwd: workspaceId, cwd: workspaceId,
model, model,
features: draftFeatures,
thinkingOptionId, thinkingOptionId,
labels: {}, labels: {},
}; };
@@ -186,6 +200,7 @@ export function WorkspaceDraftAgentTab({
...(effectiveDraftThinkingOptionId ...(effectiveDraftThinkingOptionId
? { thinkingOptionId: effectiveDraftThinkingOptionId } ? { thinkingOptionId: effectiveDraftThinkingOptionId }
: {}), : {}),
...(draftFeatureValues ? { featureValues: draftFeatureValues } : {}),
}; };
const imagesData = await encodeImages(images); const imagesData = await encodeImages(images);
@@ -215,8 +230,10 @@ export function WorkspaceDraftAgentTab({
...(effectiveDraftThinkingOptionId ...(effectiveDraftThinkingOptionId
? { thinkingOptionId: effectiveDraftThinkingOptionId } ? { thinkingOptionId: effectiveDraftThinkingOptionId }
: {}), : {}),
...(draftFeatureValues ? { featureValues: draftFeatureValues } : {}),
}; };
}, [ }, [
draftFeatureValues,
effectiveDraftModelId, effectiveDraftModelId,
effectiveDraftThinkingOptionId, effectiveDraftThinkingOptionId,
modeOptions.length, modeOptions.length,
@@ -297,6 +314,8 @@ export function WorkspaceDraftAgentTab({
thinkingOptions: availableThinkingOptions, thinkingOptions: availableThinkingOptions,
selectedThinkingOptionId, selectedThinkingOptionId,
onSelectThinkingOption: setThinkingOptionFromUser, onSelectThinkingOption: setThinkingOptionFromUser,
features: draftFeatures,
onSetFeature: setDraftFeatureValue,
disabled: isSubmitting, disabled: isSubmitting,
}} }}
/> />
@@ -51,6 +51,8 @@ export function hasMeaningfulToolCallDetail(detail: ToolCallDetail | undefined):
); );
case "plain_text": case "plain_text":
return Boolean(detail.label || detail.text); return Boolean(detail.label || detail.text);
case "plan":
return detail.text.trim().length > 0;
case "unknown": case "unknown":
return hasMeaningfulUnknownValue(detail.input) || hasMeaningfulUnknownValue(detail.output); return hasMeaningfulUnknownValue(detail.input) || hasMeaningfulUnknownValue(detail.output);
} }
+1
View File
@@ -24,6 +24,7 @@ const TOOL_DETAIL_ICONS: Record<ToolCallDetail["type"], ToolCallIconComponent> =
worktree_setup: SquareTerminal, worktree_setup: SquareTerminal,
sub_agent: Bot, sub_agent: Bot,
plain_text: Wrench, plain_text: Wrench,
plan: Brain,
unknown: Wrench, unknown: Wrench,
}; };
+18 -1
View File
@@ -37,6 +37,7 @@ import type {
OpenProjectResponseMessage, OpenProjectResponseMessage,
ArchiveWorkspaceResponseMessage, ArchiveWorkspaceResponseMessage,
ListCommandsResponse, ListCommandsResponse,
ListProviderFeaturesResponseMessage,
ListProviderModelsResponseMessage, ListProviderModelsResponseMessage,
ListProviderModesResponseMessage, ListProviderModesResponseMessage,
ListAvailableProvidersResponse, ListAvailableProvidersResponse,
@@ -216,13 +217,14 @@ type CreatePaseoWorktreePayload = Extract<
>["payload"]; >["payload"];
type FileExplorerPayload = FileExplorerResponse["payload"]; type FileExplorerPayload = FileExplorerResponse["payload"];
type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"]; type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"];
type ListProviderFeaturesPayload = ListProviderFeaturesResponseMessage["payload"];
type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"]; type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"];
type ListProviderModesPayload = ListProviderModesResponseMessage["payload"]; type ListProviderModesPayload = ListProviderModesResponseMessage["payload"];
type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"]; type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"];
type ListCommandsPayload = ListCommandsResponse["payload"]; type ListCommandsPayload = ListCommandsResponse["payload"];
type ListCommandsDraftConfig = Pick< type ListCommandsDraftConfig = Pick<
AgentSessionConfig, AgentSessionConfig,
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" "provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "featureValues"
>; >;
type ListCommandsOptions = { type ListCommandsOptions = {
requestId?: string; requestId?: string;
@@ -2521,6 +2523,21 @@ export class DaemonClient {
}); });
} }
async listProviderFeatures(
draftConfig: ListCommandsDraftConfig,
options?: { requestId?: string },
): Promise<ListProviderFeaturesPayload> {
return this.sendCorrelatedSessionRequest({
requestId: options?.requestId,
message: {
type: "list_provider_features_request",
draftConfig,
},
responseType: "list_provider_features_response",
timeout: 45000,
});
}
async listAvailableProviders(options?: { async listAvailableProviders(options?: {
requestId?: string; requestId?: string;
}): Promise<ListAvailableProvidersPayload> { }): Promise<ListAvailableProvidersPayload> {
@@ -550,6 +550,31 @@ export class AgentManager {
} }
} }
async listDraftFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
const normalizedConfig = await this.normalizeConfig(config);
const client = this.requireClient(normalizedConfig.provider);
const available = await client.isAvailable();
if (!available) {
throw new Error(
`Provider '${normalizedConfig.provider}' is not available. Please ensure the CLI is installed.`,
);
}
const session = await client.createSession(normalizedConfig);
try {
return session.features ?? [];
} finally {
try {
await session.close();
} catch (error) {
this.logger.warn(
{ err: error, provider: normalizedConfig.provider },
"Failed to close draft feature listing session",
);
}
}
}
getAgent(id: string): ManagedAgent | null { getAgent(id: string): ManagedAgent | null {
const agent = this.agents.get(id); const agent = this.agents.get(id);
return agent ? { ...agent } : null; return agent ? { ...agent } : null;
@@ -69,6 +69,7 @@ export type AgentFeatureToggle = {
id: string; id: string;
label: string; label: string;
description?: string; description?: string;
tooltip?: string;
icon?: string; icon?: string;
value: boolean; value: boolean;
}; };
@@ -78,6 +79,7 @@ export type AgentFeatureSelect = {
id: string; id: string;
label: string; label: string;
description?: string; description?: string;
tooltip?: string;
icon?: string; icon?: string;
value: string | null; value: string | null;
options: AgentSelectOption[]; options: AgentSelectOption[];
@@ -222,6 +224,10 @@ export type ToolCallDetail =
text?: string; text?: string;
icon?: ToolCallIconName; icon?: ToolCallIconName;
} }
| {
type: "plan";
text: string;
}
| { | {
type: "unknown"; type: "unknown";
input: unknown | null; input: unknown | null;
@@ -59,18 +59,10 @@ const CLAUDE_MODES: AgentProviderModeDefinition[] = [
]; ];
const CODEX_MODES: AgentProviderModeDefinition[] = [ const CODEX_MODES: AgentProviderModeDefinition[] = [
{
id: "read-only",
label: "Read Only",
description:
"Read files and answer questions. Manual approval required for edits, commands, or network ops.",
icon: "ShieldCheck",
colorTier: "safe",
},
{ {
id: "auto", id: "auto",
label: "Auto", label: "Default Permissions",
description: "Edit files and run commands but still request approval before escalating scope.", description: "Edit files and run commands with Codex's default approval flow.",
icon: "ShieldAlert", icon: "ShieldAlert",
colorTier: "moderate", colorTier: "moderate",
}, },
@@ -145,7 +137,7 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
modes: CODEX_MODES, modes: CODEX_MODES,
voice: { voice: {
enabled: true, enabled: true,
defaultModeId: "read-only", defaultModeId: "auto",
defaultModel: "gpt-5.1-codex-mini", defaultModel: "gpt-5.1-codex-mini",
}, },
}, },
@@ -35,6 +35,7 @@ describe("Codex app-server provider (e2e)", () => {
model: CODEX_TEST_MODEL, model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
}); });
expect(session.features?.some((feature) => feature.id === "plan_mode")).toBe(true);
const result = await session.run("Say hello in one sentence."); const result = await session.run("Say hello in one sentence.");
expect(result.finalText.length).toBeGreaterThan(0); expect(result.finalText.length).toBeGreaterThan(0);
@@ -5,6 +5,18 @@ import { __codexAppServerInternals } from "./codex-app-server-agent.js";
import { createTestLogger } from "../../../test-utils/test-logger.js"; import { createTestLogger } from "../../../test-utils/test-logger.js";
const CODEX_PROVIDER = "codex"; const CODEX_PROVIDER = "codex";
const TEST_COLLABORATION_MODES = [
{
name: "Code",
mode: "code",
developer_instructions: "Built-in code mode",
},
{
name: "Plan",
mode: "plan",
developer_instructions: "Built-in plan mode",
},
];
function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSessionConfig { function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSessionConfig {
return { return {
@@ -28,11 +40,13 @@ function createSession(configOverrides: Partial<AgentSessionConfig> = {}) {
) as unknown as AgentSession & { [key: string]: unknown }; ) as unknown as AgentSession & { [key: string]: unknown };
session.connected = true; session.connected = true;
session.currentThreadId = "test-thread"; session.currentThreadId = "test-thread";
session.collaborationModes = TEST_COLLABORATION_MODES;
session.refreshResolvedCollaborationMode();
return session; return session;
} }
describe("Codex app-server provider fast mode", () => { describe("Codex app-server provider features", () => {
test("features returns fast_mode toggle when model supports it", async () => { test("features returns fast and plan toggles when supported", async () => {
const session = createSession(); const session = createSession();
expect(session.features).toEqual([ expect(session.features).toEqual([
@@ -41,12 +55,23 @@ describe("Codex app-server provider fast mode", () => {
id: "fast_mode", id: "fast_mode",
label: "Fast", label: "Fast",
description: "Priority inference at 2x usage", description: "Priority inference at 2x usage",
tooltip: "Toggle fast mode",
icon: "zap", icon: "zap",
value: false, value: false,
}, },
{
type: "toggle",
id: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
value: false,
},
]); ]);
await session.setFeature?.("fast_mode", true); await session.setFeature?.("fast_mode", true);
await session.setFeature?.("plan_mode", true);
expect(session.features).toEqual([ expect(session.features).toEqual([
{ {
@@ -54,16 +79,36 @@ describe("Codex app-server provider fast mode", () => {
id: "fast_mode", id: "fast_mode",
label: "Fast", label: "Fast",
description: "Priority inference at 2x usage", description: "Priority inference at 2x usage",
tooltip: "Toggle fast mode",
icon: "zap", icon: "zap",
value: true, value: true,
}, },
{
type: "toggle",
id: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
value: true,
},
]); ]);
}); });
test("features returns empty array when model does not support fast mode", () => { test("features returns only plan toggle when model does not support fast mode", () => {
const session = createSession({ model: "gpt-3.5-turbo" }); const session = createSession({ model: "gpt-3.5-turbo" });
expect(session.features).toEqual([]); expect(session.features).toEqual([
{
type: "toggle",
id: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
value: false,
},
]);
}); });
test("setFeature('fast_mode', true) sets serviceTier to fast", async () => { test("setFeature('fast_mode', true) sets serviceTier to fast", async () => {
@@ -103,21 +148,32 @@ describe("Codex app-server provider fast mode", () => {
); );
}); });
test("constructor restores serviceTier from config.featureValues", () => { test("constructor restores feature flags from config.featureValues", () => {
const session = createSession({ const session = createSession({
featureValues: { fast_mode: true }, featureValues: { fast_mode: true, plan_mode: true },
}); });
expect((session as any).serviceTier).toBe("fast"); expect((session as any).serviceTier).toBe("fast");
expect((session as any).planModeEnabled).toBe(true);
expect(session.features).toEqual([ expect(session.features).toEqual([
{ {
type: "toggle", type: "toggle",
id: "fast_mode", id: "fast_mode",
label: "Fast", label: "Fast",
description: "Priority inference at 2x usage", description: "Priority inference at 2x usage",
tooltip: "Toggle fast mode",
icon: "zap", icon: "zap",
value: true, value: true,
}, },
{
type: "toggle",
id: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
value: true,
},
]); ]);
}); });
@@ -158,7 +214,17 @@ describe("Codex app-server provider fast mode", () => {
await session.setFeature?.("fast_mode", true); await session.setFeature?.("fast_mode", true);
await session.setModel("gpt-3.5-turbo"); await session.setModel("gpt-3.5-turbo");
expect(session.features).toEqual([]); expect(session.features).toEqual([
{
type: "toggle",
id: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
value: false,
},
]);
expect((session as any).serviceTier).toBeNull(); expect((session as any).serviceTier).toBeNull();
await session.startTurn("hello"); await session.startTurn("hello");
@@ -171,4 +237,29 @@ describe("Codex app-server provider fast mode", () => {
expect.any(Number), expect.any(Number),
); );
}); });
test("startTurn switches collaboration mode when plan mode is enabled", async () => {
const session = createSession();
const request = vi.fn().mockResolvedValue(undefined);
(session as any).client = { request };
(session as any).connected = true;
(session as any).currentThreadId = "thread-123";
(session as any).ensureThreadLoaded = vi.fn().mockResolvedValue(undefined);
(session as any).ensureThread = vi.fn().mockResolvedValue(undefined);
(session as any).buildUserInput = vi.fn().mockResolvedValue([{ type: "text", text: "hi" }]);
(session as any).resolveSlashCommandInvocation = vi.fn().mockResolvedValue(null);
await session.setFeature?.("plan_mode", true);
await session.startTurn("hello");
expect(request).toHaveBeenCalledWith(
"turn/start",
expect.objectContaining({
collaborationMode: expect.objectContaining({
mode: "plan",
}),
}),
expect.any(Number),
);
});
}); });
@@ -55,6 +55,25 @@ describe("Codex app-server provider", () => {
} }
}); });
test("maps Codex plan markdown to a synthetic plan tool call", () => {
const item = __codexAppServerInternals.mapCodexPlanToToolCall({
callId: "plan-turn-1",
text: "### Login Screen\n- Build layout\n- Add validation",
});
expect(item).toEqual({
type: "tool_call",
callId: "plan-turn-1",
name: "plan",
status: "completed",
error: null,
detail: {
type: "plan",
text: "### Login Screen\n- Build layout\n- Add validation",
},
});
});
test("maps patch notifications with object-style single change payloads", () => { test("maps patch notifications with object-style single change payloads", () => {
const item = __codexAppServerInternals.mapCodexPatchNotificationToToolCall({ const item = __codexAppServerInternals.mapCodexPatchNotificationToToolCall({
callId: "patch-object-single", callId: "patch-object-single",
@@ -2,7 +2,6 @@ import type {
AgentCapabilityFlags, AgentCapabilityFlags,
AgentClient, AgentClient,
AgentFeature, AgentFeature,
AgentFeatureToggle,
AgentLaunchContext, AgentLaunchContext,
AgentMode, AgentMode,
AgentModelDefinition, AgentModelDefinition,
@@ -52,6 +51,7 @@ import {
quoteWindowsCommand, quoteWindowsCommand,
} from "../../../utils/executable.js"; } from "../../../utils/executable.js";
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js"; import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js";
const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000; const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
const TURN_START_TIMEOUT_MS = 90 * 1000; const TURN_START_TIMEOUT_MS = 90 * 1000;
@@ -68,16 +68,10 @@ const CODEX_APP_SERVER_CAPABILITIES: AgentCapabilityFlags = {
}; };
const CODEX_MODES: AgentMode[] = [ const CODEX_MODES: AgentMode[] = [
{
id: "read-only",
label: "Read Only",
description:
"Read files and answer questions. Manual approval required for edits, commands, or network ops.",
},
{ {
id: "auto", id: "auto",
label: "Auto", label: "Default Permissions",
description: "Edit files and run commands but still request approval before escalating scope.", description: "Edit files and run commands with Codex's default approval flow.",
}, },
{ {
id: "full-access", id: "full-access",
@@ -87,20 +81,6 @@ const CODEX_MODES: AgentMode[] = [
]; ];
const DEFAULT_CODEX_MODE_ID = "auto"; const DEFAULT_CODEX_MODE_ID = "auto";
const CODEX_FAST_MODE_SUPPORTED_MODE_PREFIXES = [
"gpt-5",
"gpt-4.1",
"o3",
"o4-mini",
] as const;
const CODEX_FAST_MODE_FEATURE: AgentFeatureToggle = {
type: "toggle",
id: "fast_mode",
label: "Fast",
description: "Priority inference at 2x usage",
icon: "zap",
value: false,
};
const MODE_PRESETS: Record< const MODE_PRESETS: Record<
string, string,
@@ -156,16 +136,6 @@ function normalizeCodexModelLabel(displayName: string): string {
return displayName.replace(/\bgpt\b/gi, "GPT"); return displayName.replace(/\bgpt\b/gi, "GPT");
} }
function codexModelSupportsFastMode(modelId: string | null | undefined): boolean {
const normalizedModelId = normalizeCodexModelId(modelId);
if (!normalizedModelId) {
return false;
}
return CODEX_FAST_MODE_SUPPORTED_MODE_PREFIXES.some(
(prefix) => normalizedModelId === prefix || normalizedModelId.startsWith(prefix),
);
}
type CodexConfiguredDefaults = { type CodexConfiguredDefaults = {
model?: string; model?: string;
thinkingOptionId?: string; thinkingOptionId?: string;
@@ -714,28 +684,43 @@ function extractUserText(content: unknown): string | null {
return parts.length > 0 ? parts.join("\n") : null; return parts.length > 0 ? parts.join("\n") : null;
} }
function parsePlanTextToTodoItems(text: string): { text: string; completed: boolean }[] { function normalizePlanMarkdown(text: string): string {
const lines = text return text
.split("\n") .split("\n")
.map((line) => line.trim()) .map((line) => line.replace(/\s+$/, ""))
.filter((line) => line.length > 0); .join("\n")
if (lines.length === 0) { .trim();
return [{ text, completed: false }];
}
return lines.map((line) => ({
text: line.replace(/^[-*]\s+/, ""),
completed: false,
}));
} }
function planStepsToTodoItems(steps: Array<{ step: string; status: string }>): { function planStepsToMarkdown(steps: Array<{ step: string; status: string }>): string {
text: string; const lines = steps
completed: boolean; .map((entry) => entry.step.trim())
}[] { .filter((step) => step.length > 0)
return steps.map((entry) => ({ .map((step) => {
text: entry.step, if (/^(#{1,6}\s|[-*+]\s|\d+\.\s)/.test(step)) {
completed: entry.status === "completed", return step;
})); }
return `- ${step}`;
});
return normalizePlanMarkdown(lines.join("\n"));
}
function mapCodexPlanToToolCall(params: { callId: string; text: string }): ToolCallTimelineItem | null {
const text = normalizePlanMarkdown(params.text);
if (!text) {
return null;
}
return {
type: "tool_call",
callId: params.callId,
name: "plan",
status: "completed",
error: null,
detail: {
type: "plan",
text,
},
};
} }
type CodexPatchFileChange = { type CodexPatchFileChange = {
@@ -1120,9 +1105,12 @@ function threadItemToTimeline(
return { type: "assistant_message", text: normalizedItem.text ?? "" }; return { type: "assistant_message", text: normalizedItem.text ?? "" };
} }
case "plan": { case "plan": {
const text = normalizedItem.text ?? ""; return mapCodexPlanToToolCall({
const items = parsePlanTextToTodoItems(text); callId:
return { type: "todo", items }; nonEmptyString(normalizedItem.id ?? normalizedItem.itemId ?? undefined) ??
`plan:${normalizePlanMarkdown(normalizedItem.text ?? "")}`,
text: normalizedItem.text ?? "",
});
} }
case "reasoning": { case "reasoning": {
const summary = Array.isArray(normalizedItem.summary) const summary = Array.isArray(normalizedItem.summary)
@@ -2045,6 +2033,22 @@ function buildCodexAppServerEnv(
}; };
} }
function buildCodexAppServerInitializeParams(): {
clientInfo: { name: string; title: string; version: string };
capabilities: { experimentalApi: true };
} {
return {
clientInfo: {
name: "paseo",
title: "Paseo",
version: "0.0.0",
},
capabilities: {
experimentalApi: true,
},
};
}
class CodexAppServerAgentSession implements AgentSession { class CodexAppServerAgentSession implements AgentSession {
readonly provider = CODEX_PROVIDER; readonly provider = CODEX_PROVIDER;
readonly capabilities = CODEX_APP_SERVER_CAPABILITIES; readonly capabilities = CODEX_APP_SERVER_CAPABILITIES;
@@ -2060,6 +2064,7 @@ class CodexAppServerAgentSession implements AgentSession {
private activeForegroundTurnId: string | null = null; private activeForegroundTurnId: string | null = null;
private cachedRuntimeInfo: AgentRuntimeInfo | null = null; private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
private serviceTier: "fast" | null = null; private serviceTier: "fast" | null = null;
private planModeEnabled = false;
private historyPending = false; private historyPending = false;
private persistedHistory: AgentTimelineItem[] = []; private persistedHistory: AgentTimelineItem[] = [];
private pendingPermissions = new Map<string, AgentPermissionRequest>(); private pendingPermissions = new Map<string, AgentPermissionRequest>();
@@ -2119,6 +2124,9 @@ class CodexAppServerAgentSession implements AgentSession {
if (this.config.featureValues?.fast_mode) { if (this.config.featureValues?.fast_mode) {
this.serviceTier = "fast"; this.serviceTier = "fast";
} }
if (this.config.featureValues?.plan_mode) {
this.planModeEnabled = true;
}
if (this.resumeHandle?.sessionId) { if (this.resumeHandle?.sessionId) {
this.currentThreadId = this.resumeHandle.sessionId; this.currentThreadId = this.resumeHandle.sessionId;
@@ -2131,10 +2139,12 @@ class CodexAppServerAgentSession implements AgentSession {
} }
get features(): AgentFeature[] { get features(): AgentFeature[] {
if (!codexModelSupportsFastMode(this.config.model)) { return buildCodexFeatures({
return []; modelId: this.config.model,
} fastModeEnabled: this.serviceTier === "fast",
return [{ ...CODEX_FAST_MODE_FEATURE, value: this.serviceTier === "fast" }]; planModeEnabled: this.planModeEnabled,
planModeAvailable: this.hasPlanCollaborationMode(),
});
} }
async connect(): Promise<void> { async connect(): Promise<void> {
@@ -2144,13 +2154,7 @@ class CodexAppServerAgentSession implements AgentSession {
this.client.setNotificationHandler((method, params) => this.handleNotification(method, params)); this.client.setNotificationHandler((method, params) => this.handleNotification(method, params));
this.registerRequestHandlers(); this.registerRequestHandlers();
await this.client.request("initialize", { await this.client.request("initialize", buildCodexAppServerInitializeParams());
clientInfo: {
name: "paseo",
title: "Paseo",
version: "0.0.0",
},
});
this.client.notify("initialized", {}); this.client.notify("initialized", {});
await this.loadCollaborationModes(); await this.loadCollaborationModes();
@@ -2182,7 +2186,7 @@ class CodexAppServerAgentSession implements AgentSession {
this.logger.trace({ error }, "Failed to load collaboration modes"); this.logger.trace({ error }, "Failed to load collaboration modes");
this.collaborationModes = []; this.collaborationModes = [];
} }
this.resolvedCollaborationMode = this.resolveCollaborationMode(this.currentMode); this.refreshResolvedCollaborationMode();
} }
private async loadSkills(): Promise<void> { private async loadSkills(): Promise<void> {
@@ -2211,23 +2215,46 @@ class CodexAppServerAgentSession implements AgentSession {
} }
} }
private resolveCollaborationMode( private findCollaborationMode(
modeId: string, target: "code" | "plan",
): { mode: string; settings: Record<string, unknown>; name: string } | null { ): {
name: string;
mode?: string | null;
model?: string | null;
reasoning_effort?: string | null;
developer_instructions?: string | null;
} | null {
if (this.collaborationModes.length === 0) return null; if (this.collaborationModes.length === 0) return null;
const normalized = modeId.toLowerCase();
const findByName = (predicate: (name: string) => boolean) => const findByName = (predicate: (name: string) => boolean) =>
this.collaborationModes.find((entry) => predicate(entry.name.toLowerCase())); this.collaborationModes.find((entry) => predicate(entry.name.toLowerCase()));
let match =
normalized === "read-only" if (target === "plan") {
? findByName((name) => name.includes("read") || name.includes("plan")) return findByName((name) => name.includes("plan") || name.includes("read")) ?? null;
: normalized === "full-access"
? findByName((name) => name.includes("full") || name.includes("exec"))
: findByName((name) => name.includes("auto") || name.includes("code"));
if (!match) {
match = this.collaborationModes[0] ?? null;
} }
return (
findByName((name) => name.includes("auto") || name.includes("code")) ??
this.collaborationModes.find((entry) => {
const name = entry.name.toLowerCase();
return !name.includes("plan") && !name.includes("read");
}) ??
this.collaborationModes[0] ??
null
);
}
private hasPlanCollaborationMode(): boolean {
return this.findCollaborationMode("plan") !== null;
}
private resolveCollaborationMode(): {
mode: string;
settings: Record<string, unknown>;
name: string;
} | null {
const match = this.findCollaborationMode(this.planModeEnabled ? "plan" : "code");
if (!match) return null; if (!match) return null;
const settings: Record<string, unknown> = {}; const settings: Record<string, unknown> = {};
if (match.model) settings.model = match.model; if (match.model) settings.model = match.model;
if (match.reasoning_effort) settings.reasoning_effort = match.reasoning_effort; if (match.reasoning_effort) settings.reasoning_effort = match.reasoning_effort;
@@ -2244,6 +2271,10 @@ class CodexAppServerAgentSession implements AgentSession {
return { mode: match.mode ?? "code", settings, name: match.name }; return { mode: match.mode ?? "code", settings, name: match.name };
} }
private refreshResolvedCollaborationMode(): void {
this.resolvedCollaborationMode = this.resolveCollaborationMode();
}
private registerRequestHandlers(): void { private registerRequestHandlers(): void {
if (!this.client) return; if (!this.client) return;
@@ -2425,6 +2456,8 @@ class CodexAppServerAgentSession implements AgentSession {
timeline.push(event.item); timeline.push(event.item);
if (event.item.type === "assistant_message") { if (event.item.type === "assistant_message") {
finalText = event.item.text; finalText = event.item.text;
} else if (event.item.type === "tool_call" && event.item.detail.type === "plan") {
finalText = event.item.detail.text;
} }
return; return;
} }
@@ -2615,7 +2648,6 @@ class CodexAppServerAgentSession implements AgentSession {
async setMode(modeId: string): Promise<void> { async setMode(modeId: string): Promise<void> {
validateCodexMode(modeId); validateCodexMode(modeId);
this.currentMode = modeId; this.currentMode = modeId;
this.resolvedCollaborationMode = this.resolveCollaborationMode(modeId);
this.cachedRuntimeInfo = null; this.cachedRuntimeInfo = null;
} }
@@ -2624,13 +2656,13 @@ class CodexAppServerAgentSession implements AgentSession {
if (!codexModelSupportsFastMode(this.config.model)) { if (!codexModelSupportsFastMode(this.config.model)) {
this.serviceTier = null; this.serviceTier = null;
} }
this.resolvedCollaborationMode = this.resolveCollaborationMode(this.currentMode); this.refreshResolvedCollaborationMode();
this.cachedRuntimeInfo = null; this.cachedRuntimeInfo = null;
} }
async setThinkingOption(thinkingOptionId: string | null): Promise<void> { async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
this.config.thinkingOptionId = normalizeCodexThinkingOptionId(thinkingOptionId); this.config.thinkingOptionId = normalizeCodexThinkingOptionId(thinkingOptionId);
this.resolvedCollaborationMode = this.resolveCollaborationMode(this.currentMode); this.refreshResolvedCollaborationMode();
this.cachedRuntimeInfo = null; this.cachedRuntimeInfo = null;
} }
@@ -2640,6 +2672,12 @@ class CodexAppServerAgentSession implements AgentSession {
this.cachedRuntimeInfo = null; this.cachedRuntimeInfo = null;
return; return;
} }
if (featureId === "plan_mode") {
this.planModeEnabled = Boolean(value);
this.refreshResolvedCollaborationMode();
this.cachedRuntimeInfo = null;
return;
}
throw new Error(`Unknown Codex feature: ${featureId}`); throw new Error(`Unknown Codex feature: ${featureId}`);
} }
@@ -2966,17 +3004,22 @@ class CodexAppServerAgentSession implements AgentSession {
} }
if (parsed.kind === "plan_updated") { if (parsed.kind === "plan_updated") {
const items = planStepsToTodoItems( const timelineItem = mapCodexPlanToToolCall({
parsed.plan.map((entry) => ({ callId: `plan:${this.currentTurnId ?? this.currentThreadId ?? "current"}`,
step: entry.step ?? "", text: planStepsToMarkdown(
status: entry.status ?? "pending", parsed.plan.map((entry) => ({
})), step: entry.step ?? "",
); status: entry.status ?? "pending",
this.emitEvent({ })),
type: "timeline", ),
provider: CODEX_PROVIDER,
item: { type: "todo", items },
}); });
if (timelineItem) {
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: timelineItem,
});
}
return; return;
} }
@@ -3520,9 +3563,7 @@ export class CodexAppServerAgentClient implements AgentClient {
const client = new CodexAppServerClient(child, this.logger); const client = new CodexAppServerClient(child, this.logger);
try { try {
await client.request("initialize", { await client.request("initialize", buildCodexAppServerInitializeParams());
clientInfo: { name: "paseo", title: "Paseo", version: "0.0.0" },
});
client.notify("initialized", {}); client.notify("initialized", {});
const limit = options?.limit ?? 20; const limit = options?.limit ?? 20;
@@ -3592,13 +3633,7 @@ export class CodexAppServerAgentClient implements AgentClient {
const client = new CodexAppServerClient(child, this.logger); const client = new CodexAppServerClient(child, this.logger);
try { try {
await client.request("initialize", { await client.request("initialize", buildCodexAppServerInitializeParams());
clientInfo: {
name: "paseo",
title: "Paseo",
version: "0.0.0",
},
});
client.notify("initialized", {}); client.notify("initialized", {});
const response = (await client.request("model/list", {})) as { data?: Array<any> }; const response = (await client.request("model/list", {})) as { data?: Array<any> };
@@ -3689,4 +3724,7 @@ export const __codexAppServerInternals = {
codexModelSupportsFastMode, codexModelSupportsFastMode,
CodexAppServerAgentSession, CodexAppServerAgentSession,
mapCodexPatchNotificationToToolCall, mapCodexPatchNotificationToToolCall,
planStepsToMarkdown,
mapCodexPlanToToolCall,
threadItemToTimeline,
}; };
@@ -0,0 +1,66 @@
import type { AgentFeature, AgentFeatureToggle } from "../agent-sdk-types.js";
const CODEX_FAST_MODE_SUPPORTED_MODEL_PREFIXES = [
"gpt-5",
"gpt-4.1",
"o3",
"o4-mini",
] as const;
export const CODEX_FAST_MODE_FEATURE: Omit<AgentFeatureToggle, "value"> = {
type: "toggle",
id: "fast_mode",
label: "Fast",
description: "Priority inference at 2x usage",
tooltip: "Toggle fast mode",
icon: "zap",
};
export const CODEX_PLAN_MODE_FEATURE: Omit<AgentFeatureToggle, "value"> = {
type: "toggle",
id: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
};
function normalizeCodexModelId(modelId: string | null | undefined): string | null {
const normalized = typeof modelId === "string" ? modelId.trim() : "";
return normalized.length > 0 ? normalized : null;
}
export function codexModelSupportsFastMode(modelId: string | null | undefined): boolean {
const normalizedModelId = normalizeCodexModelId(modelId);
if (!normalizedModelId) {
return false;
}
return CODEX_FAST_MODE_SUPPORTED_MODEL_PREFIXES.some(
(prefix) => normalizedModelId === prefix || normalizedModelId.startsWith(prefix),
);
}
export function buildCodexFeatures(input: {
modelId: string | null | undefined;
fastModeEnabled: boolean;
planModeEnabled: boolean;
planModeAvailable?: boolean;
}): AgentFeature[] {
const features: AgentFeature[] = [];
if (codexModelSupportsFastMode(input.modelId)) {
features.push({
...CODEX_FAST_MODE_FEATURE,
value: input.fastModeEnabled,
});
}
if (input.planModeAvailable !== false) {
features.push({
...CODEX_PLAN_MODE_FEATURE,
value: input.planModeEnabled,
});
}
return features;
}
@@ -0,0 +1,64 @@
import { describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { isProviderAvailable } from "../../daemon-e2e/agent-configs.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "codex-plan-mode-real-"));
}
describe("Codex app-server provider (real) plan mode", () => {
test.runIf(isProviderAvailable("codex"))(
"maps gpt-5.4 markdown plans to a plan tool call instead of todo items",
async () => {
const cwd = tmpCwd();
const client = new CodexAppServerAgentClient(createTestLogger());
try {
const session = await client.createSession({
provider: "codex",
cwd,
modeId: "auto",
model: "gpt-5.4",
thinkingOptionId: "medium",
});
try {
await session.setFeature?.("plan_mode", true);
const result = await session.run(
"You are in plan mode. Produce a markdown plan with a short heading and exactly 3 bullets for implementing a login screen. Do not ask questions.",
);
expect(result.timeline).not.toContainEqual(
expect.objectContaining({
type: "todo",
}),
);
const planCall = result.timeline.find(
(item) => item.type === "tool_call" && item.detail.type === "plan",
);
expect(planCall).toBeDefined();
if (!planCall || planCall.type !== "tool_call" || planCall.detail.type !== "plan") {
throw new Error("Expected a plan tool call");
}
expect(planCall.detail.text).toContain("Login");
expect(planCall.detail.text).toContain("- ");
expect(result.finalText).toBe(planCall.detail.text);
} finally {
await session.close();
}
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
240_000,
);
});
+59 -10
View File
@@ -1700,6 +1700,10 @@ export class Session {
await this.handleListProviderModesRequest(msg); await this.handleListProviderModesRequest(msg);
break; break;
case "list_provider_features_request":
await this.handleListProviderFeaturesRequest(msg);
break;
case "list_available_providers_request": case "list_available_providers_request":
await this.handleListAvailableProvidersRequest(msg); await this.handleListAvailableProvidersRequest(msg);
break; break;
@@ -3116,6 +3120,60 @@ export class Session {
} }
} }
private buildDraftAgentSessionConfig(draftConfig: {
provider: AgentProvider;
cwd: string;
modeId?: string;
model?: string;
thinkingOptionId?: string;
featureValues?: Record<string, unknown>;
}): AgentSessionConfig {
return {
provider: draftConfig.provider,
cwd: expandTilde(draftConfig.cwd),
...(draftConfig.modeId ? { modeId: draftConfig.modeId } : {}),
...(draftConfig.model ? { model: draftConfig.model } : {}),
...(draftConfig.thinkingOptionId
? { thinkingOptionId: draftConfig.thinkingOptionId }
: {}),
...(draftConfig.featureValues ? { featureValues: draftConfig.featureValues } : {}),
};
}
private async handleListProviderFeaturesRequest(
msg: Extract<SessionInboundMessage, { type: "list_provider_features_request" }>,
): Promise<void> {
const fetchedAt = new Date().toISOString();
try {
const sessionConfig = this.buildDraftAgentSessionConfig(msg.draftConfig);
const features = await this.agentManager.listDraftFeatures(sessionConfig);
this.emit({
type: "list_provider_features_response",
payload: {
provider: msg.draftConfig.provider,
features,
error: null,
fetchedAt,
requestId: msg.requestId,
},
});
} catch (error) {
this.sessionLogger.error(
{ err: error, provider: msg.draftConfig.provider, draftConfig: msg.draftConfig },
`Failed to list features for ${msg.draftConfig.provider}`,
);
this.emit({
type: "list_provider_features_response",
payload: {
provider: msg.draftConfig.provider,
error: (error as Error)?.message ?? String(error),
fetchedAt,
requestId: msg.requestId,
},
});
}
}
private async handleListAvailableProvidersRequest( private async handleListAvailableProvidersRequest(
msg: Extract<SessionInboundMessage, { type: "list_available_providers_request" }>, msg: Extract<SessionInboundMessage, { type: "list_available_providers_request" }>,
): Promise<void> { ): Promise<void> {
@@ -3632,16 +3690,7 @@ export class Session {
} }
if (!agent && draftConfig) { if (!agent && draftConfig) {
const sessionConfig: AgentSessionConfig = { const sessionConfig = this.buildDraftAgentSessionConfig(draftConfig);
provider: draftConfig.provider,
cwd: expandTilde(draftConfig.cwd),
...(draftConfig.modeId ? { modeId: draftConfig.modeId } : {}),
...(draftConfig.model ? { model: draftConfig.model } : {}),
...(draftConfig.thinkingOptionId
? { thinkingOptionId: draftConfig.thinkingOptionId }
: {}),
};
const commands = await this.agentManager.listDraftCommands(sessionConfig); const commands = await this.agentManager.listDraftCommands(sessionConfig);
this.emit({ this.emit({
type: "list_commands_response", type: "list_commands_response",
@@ -14,6 +14,7 @@ describe("agent feature schemas", () => {
id: "fast_mode", id: "fast_mode",
label: "Fast mode", label: "Fast mode",
description: "Uses lower latency service tier", description: "Uses lower latency service tier",
tooltip: "Toggle fast mode",
icon: "bolt", icon: "bolt",
value: true, value: true,
}); });
@@ -31,6 +32,7 @@ describe("agent feature schemas", () => {
id: "service_tier", id: "service_tier",
label: "Service tier", label: "Service tier",
description: "Choose a processing tier", description: "Choose a processing tier",
tooltip: "Select service tier",
icon: "gauge", icon: "gauge",
value: "flex", value: "flex",
options: [ options: [
@@ -112,6 +114,7 @@ describe("agent feature schemas", () => {
type: "toggle", type: "toggle",
id: "fast_mode", id: "fast_mode",
label: "Fast mode", label: "Fast mode",
tooltip: "Toggle fast mode",
value: false, value: false,
}, },
], ],
@@ -27,6 +27,9 @@ describe("list_commands_request schema", () => {
modeId: "bypassPermissions", modeId: "bypassPermissions",
model: "gpt-5", model: "gpt-5",
thinkingOptionId: "off", thinkingOptionId: "off",
featureValues: {
plan_mode: true,
},
}, },
requestId: "req-456", requestId: "req-456",
}); });
@@ -41,6 +44,9 @@ describe("list_commands_request schema", () => {
modeId: "bypassPermissions", modeId: "bypassPermissions",
model: "gpt-5", model: "gpt-5",
thinkingOptionId: "off", thinkingOptionId: "off",
featureValues: {
plan_mode: true,
},
}); });
}); });
}); });
+32
View File
@@ -82,6 +82,7 @@ export const AgentFeatureToggleSchema = z.object({
id: z.string(), id: z.string(),
label: z.string(), label: z.string(),
description: z.string().optional(), description: z.string().optional(),
tooltip: z.string().optional(),
icon: z.string().optional(), icon: z.string().optional(),
value: z.boolean(), value: z.boolean(),
}); });
@@ -91,6 +92,7 @@ export const AgentFeatureSelectSchema = z.object({
id: z.string(), id: z.string(),
label: z.string(), label: z.string(),
description: z.string().optional(), description: z.string().optional(),
tooltip: z.string().optional(),
icon: z.string().optional(), icon: z.string().optional(),
value: z.string().nullable(), value: z.string().nullable(),
options: z.array(AgentSelectOptionSchema), options: z.array(AgentSelectOptionSchema),
@@ -315,6 +317,10 @@ const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail> = z.discriminatedUn
text: z.string().optional(), text: z.string().optional(),
icon: z.enum(TOOL_CALL_ICON_NAMES).optional(), icon: z.enum(TOOL_CALL_ICON_NAMES).optional(),
}), }),
z.object({
type: z.literal("plan"),
text: z.string(),
}),
z.object({ z.object({
type: z.literal("unknown"), type: z.literal("unknown"),
input: UnknownValueSchema, input: UnknownValueSchema,
@@ -1154,6 +1160,13 @@ const ListCommandsDraftConfigSchema = z.object({
modeId: z.string().optional(), modeId: z.string().optional(),
model: z.string().optional(), model: z.string().optional(),
thinkingOptionId: z.string().optional(), thinkingOptionId: z.string().optional(),
featureValues: z.record(z.unknown()).optional(),
});
export const ListProviderFeaturesRequestMessageSchema = z.object({
type: z.literal("list_provider_features_request"),
draftConfig: ListCommandsDraftConfigSchema,
requestId: z.string(),
}); });
export const ListCommandsRequestSchema = z.object({ export const ListCommandsRequestSchema = z.object({
@@ -1260,6 +1273,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
CreateAgentRequestMessageSchema, CreateAgentRequestMessageSchema,
ListProviderModelsRequestMessageSchema, ListProviderModelsRequestMessageSchema,
ListProviderModesRequestMessageSchema, ListProviderModesRequestMessageSchema,
ListProviderFeaturesRequestMessageSchema,
ListAvailableProvidersRequestMessageSchema, ListAvailableProvidersRequestMessageSchema,
ResumeAgentRequestMessageSchema, ResumeAgentRequestMessageSchema,
RefreshAgentRequestMessageSchema, RefreshAgentRequestMessageSchema,
@@ -2179,6 +2193,17 @@ export const ListProviderModesResponseMessageSchema = z.object({
}), }),
}); });
export const ListProviderFeaturesResponseMessageSchema = z.object({
type: z.literal("list_provider_features_response"),
payload: z.object({
provider: AgentProviderSchema,
features: z.array(AgentFeatureSchema).optional(),
error: z.string().nullable().optional(),
fetchedAt: z.string(),
requestId: z.string(),
}),
});
const ProviderAvailabilitySchema = z.object({ const ProviderAvailabilitySchema = z.object({
provider: AgentProviderSchema, provider: AgentProviderSchema,
available: z.boolean(), available: z.boolean(),
@@ -2386,6 +2411,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
FileDownloadTokenResponseSchema, FileDownloadTokenResponseSchema,
ListProviderModelsResponseMessageSchema, ListProviderModelsResponseMessageSchema,
ListProviderModesResponseMessageSchema, ListProviderModesResponseMessageSchema,
ListProviderFeaturesResponseMessageSchema,
ListAvailableProvidersResponseSchema, ListAvailableProvidersResponseSchema,
ListCommandsResponseSchema, ListCommandsResponseSchema,
ListTerminalsResponseSchema, ListTerminalsResponseSchema,
@@ -2462,6 +2488,9 @@ export type ListProviderModelsResponseMessage = z.infer<
export type ListProviderModesResponseMessage = z.infer< export type ListProviderModesResponseMessage = z.infer<
typeof ListProviderModesResponseMessageSchema typeof ListProviderModesResponseMessageSchema
>; >;
export type ListProviderFeaturesResponseMessage = z.infer<
typeof ListProviderFeaturesResponseMessageSchema
>;
export type ListAvailableProvidersResponse = z.infer<typeof ListAvailableProvidersResponseSchema>; export type ListAvailableProvidersResponse = z.infer<typeof ListAvailableProvidersResponseSchema>;
export type ChatCreateResponse = z.infer<typeof ChatCreateResponseSchema>; export type ChatCreateResponse = z.infer<typeof ChatCreateResponseSchema>;
export type ChatListResponse = z.infer<typeof ChatListResponseSchema>; export type ChatListResponse = z.infer<typeof ChatListResponseSchema>;
@@ -2504,6 +2533,9 @@ export type ListProviderModelsRequestMessage = z.infer<
export type ListProviderModesRequestMessage = z.infer< export type ListProviderModesRequestMessage = z.infer<
typeof ListProviderModesRequestMessageSchema typeof ListProviderModesRequestMessageSchema
>; >;
export type ListProviderFeaturesRequestMessage = z.infer<
typeof ListProviderFeaturesRequestMessageSchema
>;
export type ListAvailableProvidersRequestMessage = z.infer< export type ListAvailableProvidersRequestMessage = z.infer<
typeof ListAvailableProvidersRequestMessageSchema typeof ListAvailableProvidersRequestMessageSchema
>; >;
@@ -140,4 +140,20 @@ describe("shared tool-call display mapping", () => {
summary: "npm run test", summary: "npm run test",
}); });
}); });
it("labels plan detail rows as Plan", () => {
const display = buildToolCallDisplayModel({
name: "plan",
status: "completed",
error: null,
detail: {
type: "plan",
text: "### Login Screen\n- Build layout",
},
});
expect(display).toEqual({
displayName: "Plan",
});
});
}); });
@@ -109,6 +109,10 @@ function buildCanonicalDetailDisplay(input: ToolCallDisplayInput): DetailDisplay
return { return {
summary: input.detail.label, summary: input.detail.label,
}; };
case "plan":
return {
displayName: "Plan",
};
case "unknown": case "unknown":
return {}; return {};
} }