Add Codex plan mode draft features
This commit is contained in:
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user