Update files
This commit is contained in:
@@ -49,6 +49,7 @@ import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
|||||||
import type { DaemonClient } from "@server/client/daemon-client";
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
import { parseToolCallDisplay } from "@/utils/tool-call-parsers";
|
import { parseToolCallDisplay } from "@/utils/tool-call-parsers";
|
||||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||||
|
import { QuestionFormCard } from "./question-form-card";
|
||||||
import { ToolCallSheetProvider } from "./tool-call-sheet";
|
import { ToolCallSheetProvider } from "./tool-call-sheet";
|
||||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||||
@@ -1070,6 +1071,16 @@ function PermissionRequestCard({
|
|||||||
[permission.agentId, permission.request.id, respondToPermission]
|
[permission.agentId, permission.request.id, respondToPermission]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (request.kind === "question") {
|
||||||
|
return (
|
||||||
|
<QuestionFormCard
|
||||||
|
permission={permission}
|
||||||
|
onRespond={handleResponse}
|
||||||
|
isResponding={isResponding}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
|
|||||||
@@ -147,8 +147,12 @@ export const UserMessage = memo(function UserMessage({
|
|||||||
isLastInGroup = true,
|
isLastInGroup = true,
|
||||||
disableOuterSpacing,
|
disableOuterSpacing,
|
||||||
}: UserMessageProps) {
|
}: UserMessageProps) {
|
||||||
|
const [messageHovered, setMessageHovered] = useState(false);
|
||||||
|
const [copyButtonHovered, setCopyButtonHovered] = useState(false);
|
||||||
const resolvedDisableOuterSpacing =
|
const resolvedDisableOuterSpacing =
|
||||||
useDisableOuterSpacing(disableOuterSpacing);
|
useDisableOuterSpacing(disableOuterSpacing);
|
||||||
|
const showCopyButton =
|
||||||
|
Platform.OS !== "web" || messageHovered || copyButtonHovered;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -163,29 +167,31 @@ export const UserMessage = memo(function UserMessage({
|
|||||||
],
|
],
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Pressable style={userMessageStylesheet.content}>
|
<Pressable
|
||||||
{({ hovered }) => {
|
style={userMessageStylesheet.content}
|
||||||
const showCopyButton = Platform.OS !== "web" || hovered;
|
onHoverIn={
|
||||||
return (
|
Platform.OS === "web" ? () => setMessageHovered(true) : undefined
|
||||||
<>
|
}
|
||||||
<View style={userMessageStylesheet.bubble}>
|
onHoverOut={
|
||||||
<Text selectable style={userMessageStylesheet.text}>
|
Platform.OS === "web" ? () => setMessageHovered(false) : undefined
|
||||||
{message}
|
}
|
||||||
</Text>
|
>
|
||||||
</View>
|
<View style={userMessageStylesheet.bubble}>
|
||||||
<TurnCopyButton
|
<Text selectable style={userMessageStylesheet.text}>
|
||||||
getContent={() => message}
|
{message}
|
||||||
containerStyle={[
|
</Text>
|
||||||
userMessageStylesheet.copyButton,
|
</View>
|
||||||
showCopyButton
|
<TurnCopyButton
|
||||||
? userMessageStylesheet.copyButtonVisible
|
getContent={() => message}
|
||||||
: userMessageStylesheet.copyButtonHidden,
|
containerStyle={[
|
||||||
]}
|
userMessageStylesheet.copyButton,
|
||||||
accessibilityLabel="Copy message"
|
showCopyButton
|
||||||
/>
|
? userMessageStylesheet.copyButtonVisible
|
||||||
</>
|
: userMessageStylesheet.copyButtonHidden,
|
||||||
);
|
]}
|
||||||
}}
|
accessibilityLabel="Copy message"
|
||||||
|
onHoverChange={setCopyButtonHovered}
|
||||||
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -251,6 +257,7 @@ interface TurnCopyButtonProps {
|
|||||||
containerStyle?: StyleProp<ViewStyle>;
|
containerStyle?: StyleProp<ViewStyle>;
|
||||||
accessibilityLabel?: string;
|
accessibilityLabel?: string;
|
||||||
copiedAccessibilityLabel?: string;
|
copiedAccessibilityLabel?: string;
|
||||||
|
onHoverChange?: (hovered: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TurnCopyButton = memo(function TurnCopyButton({
|
export const TurnCopyButton = memo(function TurnCopyButton({
|
||||||
@@ -258,6 +265,7 @@ export const TurnCopyButton = memo(function TurnCopyButton({
|
|||||||
containerStyle,
|
containerStyle,
|
||||||
accessibilityLabel,
|
accessibilityLabel,
|
||||||
copiedAccessibilityLabel,
|
copiedAccessibilityLabel,
|
||||||
|
onHoverChange,
|
||||||
}: TurnCopyButtonProps) {
|
}: TurnCopyButtonProps) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@@ -292,6 +300,8 @@ export const TurnCopyButton = memo(function TurnCopyButton({
|
|||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleCopy}
|
onPress={handleCopy}
|
||||||
|
onHoverIn={Platform.OS === "web" ? () => onHoverChange?.(true) : undefined}
|
||||||
|
onHoverOut={Platform.OS === "web" ? () => onHoverChange?.(false) : undefined}
|
||||||
style={[turnCopyButtonStylesheet.container, containerStyle]}
|
style={[turnCopyButtonStylesheet.container, containerStyle]}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={
|
accessibilityLabel={
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { View, Text, TextInput, Pressable, ActivityIndicator } from "react-native";
|
||||||
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { Check, X, Send } from "lucide-react-native";
|
||||||
|
import type { PendingPermission } from "@/types/shared";
|
||||||
|
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
|
||||||
|
|
||||||
|
interface QuestionOption {
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Question {
|
||||||
|
question: string;
|
||||||
|
header: string;
|
||||||
|
options: QuestionOption[];
|
||||||
|
multiSelect: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseQuestions(input: unknown): Question[] | null {
|
||||||
|
if (
|
||||||
|
typeof input !== "object" ||
|
||||||
|
input === null ||
|
||||||
|
!("questions" in input) ||
|
||||||
|
!Array.isArray((input as Record<string, unknown>).questions)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const raw = (input as Record<string, unknown>).questions as unknown[];
|
||||||
|
const questions: Question[] = [];
|
||||||
|
for (const item of raw) {
|
||||||
|
if (typeof item !== "object" || item === null) return null;
|
||||||
|
const q = item as Record<string, unknown>;
|
||||||
|
if (typeof q.question !== "string" || typeof q.header !== "string") return null;
|
||||||
|
if (!Array.isArray(q.options)) return null;
|
||||||
|
const options: QuestionOption[] = [];
|
||||||
|
for (const opt of q.options as unknown[]) {
|
||||||
|
if (typeof opt !== "object" || opt === null) return null;
|
||||||
|
const o = opt as Record<string, unknown>;
|
||||||
|
if (typeof o.label !== "string") return null;
|
||||||
|
options.push({
|
||||||
|
label: o.label,
|
||||||
|
description: typeof o.description === "string" ? o.description : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
questions.push({
|
||||||
|
question: q.question,
|
||||||
|
header: q.header,
|
||||||
|
options,
|
||||||
|
multiSelect: q.multiSelect === true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return questions.length > 0 ? questions : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuestionFormCardProps {
|
||||||
|
permission: PendingPermission;
|
||||||
|
onRespond: (response: AgentPermissionResponse) => void;
|
||||||
|
isResponding: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuestionFormCard({
|
||||||
|
permission,
|
||||||
|
onRespond,
|
||||||
|
isResponding,
|
||||||
|
}: QuestionFormCardProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const questions = parseQuestions(permission.request.input);
|
||||||
|
|
||||||
|
// selections[questionIndex] = Set of selected option indices
|
||||||
|
const [selections, setSelections] = useState<Record<number, Set<number>>>({});
|
||||||
|
// otherTexts[questionIndex] = custom "Other" text
|
||||||
|
const [otherTexts, setOtherTexts] = useState<Record<number, string>>({});
|
||||||
|
|
||||||
|
const toggleOption = useCallback(
|
||||||
|
(qIndex: number, optIndex: number, multiSelect: boolean) => {
|
||||||
|
setSelections((prev) => {
|
||||||
|
const current = prev[qIndex] ?? new Set<number>();
|
||||||
|
const next = new Set(current);
|
||||||
|
if (multiSelect) {
|
||||||
|
if (next.has(optIndex)) {
|
||||||
|
next.delete(optIndex);
|
||||||
|
} else {
|
||||||
|
next.add(optIndex);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (next.has(optIndex)) {
|
||||||
|
next.clear();
|
||||||
|
} else {
|
||||||
|
next.clear();
|
||||||
|
next.add(optIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...prev, [qIndex]: next };
|
||||||
|
});
|
||||||
|
// Clear "Other" text when an option is selected
|
||||||
|
setOtherTexts((prev) => {
|
||||||
|
if (!prev[qIndex]) return prev;
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[qIndex];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setOtherText = useCallback((qIndex: number, text: string) => {
|
||||||
|
setOtherTexts((prev) => ({ ...prev, [qIndex]: text }));
|
||||||
|
// Clear option selections when typing "Other"
|
||||||
|
if (text.length > 0) {
|
||||||
|
setSelections((prev) => {
|
||||||
|
if (!prev[qIndex] || prev[qIndex].size === 0) return prev;
|
||||||
|
return { ...prev, [qIndex]: new Set<number>() };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!questions) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allAnswered = questions.every((_, qIndex) => {
|
||||||
|
const selected = selections[qIndex];
|
||||||
|
const otherText = otherTexts[qIndex]?.trim();
|
||||||
|
return (selected && selected.size > 0) || (otherText && otherText.length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
const answers: Record<string, string> = {};
|
||||||
|
for (let i = 0; i < questions!.length; i++) {
|
||||||
|
const q = questions![i];
|
||||||
|
const selected = selections[i];
|
||||||
|
const otherText = otherTexts[i]?.trim();
|
||||||
|
|
||||||
|
if (otherText && otherText.length > 0) {
|
||||||
|
answers[q.header] = otherText;
|
||||||
|
} else if (selected && selected.size > 0) {
|
||||||
|
const labels = Array.from(selected).map((idx) => q.options[idx].label);
|
||||||
|
answers[q.header] = labels.join(", ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onRespond({
|
||||||
|
behavior: "allow",
|
||||||
|
updatedInput: { ...permission.request.input, answers },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeny() {
|
||||||
|
onRespond({
|
||||||
|
behavior: "deny",
|
||||||
|
message: "Dismissed by user",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
{
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{questions.map((q, qIndex) => {
|
||||||
|
const selected = selections[qIndex] ?? new Set<number>();
|
||||||
|
const otherText = otherTexts[qIndex] ?? "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={qIndex} style={styles.questionBlock}>
|
||||||
|
<Text
|
||||||
|
style={[styles.header, { color: theme.colors.foregroundMuted }]}
|
||||||
|
>
|
||||||
|
{q.header}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[styles.questionText, { color: theme.colors.foreground }]}
|
||||||
|
>
|
||||||
|
{q.question}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.optionsWrap}>
|
||||||
|
{q.options.map((opt, optIndex) => {
|
||||||
|
const isSelected = selected.has(optIndex);
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={optIndex}
|
||||||
|
style={(state) => {
|
||||||
|
const hovered = Boolean((state as any).hovered);
|
||||||
|
return [
|
||||||
|
styles.chip,
|
||||||
|
{
|
||||||
|
borderColor: isSelected
|
||||||
|
? theme.colors.accent
|
||||||
|
: theme.colors.border,
|
||||||
|
backgroundColor: isSelected
|
||||||
|
? `${theme.colors.accent}18`
|
||||||
|
: hovered
|
||||||
|
? theme.colors.surface1
|
||||||
|
: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}}
|
||||||
|
onPress={() => toggleOption(qIndex, optIndex, q.multiSelect)}
|
||||||
|
disabled={isResponding}
|
||||||
|
>
|
||||||
|
<View style={styles.chipContent}>
|
||||||
|
{q.multiSelect && isSelected ? (
|
||||||
|
<Check size={14} color={theme.colors.accent} />
|
||||||
|
) : null}
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.chipLabel,
|
||||||
|
{
|
||||||
|
color: isSelected
|
||||||
|
? theme.colors.accent
|
||||||
|
: theme.colors.foreground,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{opt.description ? (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.chipDescription,
|
||||||
|
{ color: theme.colors.foregroundMuted },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{opt.description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
<TextInput
|
||||||
|
style={[
|
||||||
|
styles.otherInput,
|
||||||
|
{
|
||||||
|
borderColor: otherText.length > 0
|
||||||
|
? theme.colors.accent
|
||||||
|
: theme.colors.border,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
backgroundColor: theme.colors.surface0,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
placeholder="Other..."
|
||||||
|
placeholderTextColor={theme.colors.foregroundMuted}
|
||||||
|
value={otherText}
|
||||||
|
onChangeText={(text) => setOtherText(qIndex, text)}
|
||||||
|
editable={!isResponding}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Pressable
|
||||||
|
style={(state) => {
|
||||||
|
const hovered = Boolean((state as any).hovered);
|
||||||
|
return [
|
||||||
|
styles.actionButton,
|
||||||
|
{
|
||||||
|
backgroundColor: hovered
|
||||||
|
? theme.colors.surface1
|
||||||
|
: theme.colors.surface2,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}}
|
||||||
|
onPress={handleDeny}
|
||||||
|
disabled={isResponding}
|
||||||
|
>
|
||||||
|
{isResponding ? (
|
||||||
|
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||||
|
) : (
|
||||||
|
<View style={styles.actionContent}>
|
||||||
|
<X size={14} color={theme.colors.foregroundMuted} />
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.actionText,
|
||||||
|
{ color: theme.colors.foregroundMuted },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
style={(state) => {
|
||||||
|
const hovered = Boolean((state as any).hovered);
|
||||||
|
const disabled = !allAnswered || isResponding;
|
||||||
|
return [
|
||||||
|
styles.actionButton,
|
||||||
|
{
|
||||||
|
backgroundColor: hovered && !disabled
|
||||||
|
? theme.colors.surface1
|
||||||
|
: theme.colors.surface2,
|
||||||
|
borderColor: disabled
|
||||||
|
? theme.colors.border
|
||||||
|
: theme.colors.accent,
|
||||||
|
opacity: disabled ? 0.5 : 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}}
|
||||||
|
onPress={handleSubmit}
|
||||||
|
disabled={!allAnswered || isResponding}
|
||||||
|
>
|
||||||
|
{isResponding ? (
|
||||||
|
<ActivityIndicator size="small" color={theme.colors.accent} />
|
||||||
|
) : (
|
||||||
|
<View style={styles.actionContent}>
|
||||||
|
<Send size={14} color={allAnswered ? theme.colors.accent : theme.colors.foregroundMuted} />
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.actionText,
|
||||||
|
{ color: allAnswered ? theme.colors.accent : theme.colors.foregroundMuted },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
container: {
|
||||||
|
marginVertical: theme.spacing[3],
|
||||||
|
padding: theme.spacing[3],
|
||||||
|
borderRadius: theme.spacing[2],
|
||||||
|
borderWidth: 1,
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
},
|
||||||
|
questionBlock: {
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
questionText: {
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
fontWeight: theme.fontWeight.medium,
|
||||||
|
},
|
||||||
|
optionsWrap: {
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
chip: {
|
||||||
|
paddingVertical: theme.spacing[2],
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
},
|
||||||
|
chipContent: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
},
|
||||||
|
chipLabel: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.medium,
|
||||||
|
},
|
||||||
|
chipDescription: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
lineHeight: 16,
|
||||||
|
},
|
||||||
|
otherInput: {
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
paddingVertical: theme.spacing[2],
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
marginTop: theme.spacing[1],
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: theme.spacing[2],
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
alignItems: "center",
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
},
|
||||||
|
actionContent: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
actionText: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.normal,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -165,7 +165,7 @@ export type AgentStreamEvent =
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AgentPermissionRequestKind = "tool" | "plan" | "mode" | "other";
|
export type AgentPermissionRequestKind = "tool" | "plan" | "question" | "mode" | "other";
|
||||||
|
|
||||||
export type AgentPermissionUpdate = AgentMetadata;
|
export type AgentPermissionUpdate = AgentMetadata;
|
||||||
|
|
||||||
|
|||||||
@@ -1247,6 +1247,36 @@ describe("convertClaudeHistoryEntry", () => {
|
|||||||
|
|
||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("passes thinking blocks to mapBlocks for assistant entries", () => {
|
||||||
|
const entry = {
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "thinking", thinking: "Let me reason about this..." },
|
||||||
|
{ type: "text", text: "Here is my answer." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapBlocks = vi.fn().mockReturnValue([
|
||||||
|
{ type: "reasoning", text: "Let me reason about this..." },
|
||||||
|
{ type: "assistant_message", text: "Here is my answer." },
|
||||||
|
]);
|
||||||
|
const result = convertClaudeHistoryEntry(entry, mapBlocks);
|
||||||
|
|
||||||
|
expect(mapBlocks).toHaveBeenCalledTimes(1);
|
||||||
|
const arg = mapBlocks.mock.calls[0][0];
|
||||||
|
expect(arg).toEqual([
|
||||||
|
{ type: "thinking", thinking: "Let me reason about this..." },
|
||||||
|
{ type: "text", text: "Here is my answer." },
|
||||||
|
]);
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ type: "reasoning", text: "Let me reason about this..." },
|
||||||
|
{ type: "assistant_message", text: "Here is my answer." },
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
type StreamHydrationUpdate = {
|
type StreamHydrationUpdate = {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import type {
|
|||||||
AgentMode,
|
AgentMode,
|
||||||
AgentModelDefinition,
|
AgentModelDefinition,
|
||||||
AgentPermissionRequest,
|
AgentPermissionRequest,
|
||||||
|
AgentPermissionRequestKind,
|
||||||
AgentPermissionResponse,
|
AgentPermissionResponse,
|
||||||
AgentPermissionUpdate,
|
AgentPermissionUpdate,
|
||||||
AgentPersistenceHandle,
|
AgentPersistenceHandle,
|
||||||
@@ -328,6 +329,17 @@ function isPermissionUpdate(value: AgentPermissionUpdate): value is PermissionUp
|
|||||||
return Array.isArray(rules) && typeof behavior === "string" && typeof destination === "string";
|
return Array.isArray(rules) && typeof behavior === "string" && typeof destination === "string";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolvePermissionKind(
|
||||||
|
toolName: string,
|
||||||
|
input: Record<string, unknown>
|
||||||
|
): AgentPermissionRequestKind {
|
||||||
|
if (toolName === "ExitPlanMode") return "plan";
|
||||||
|
if (toolName === "AskUserQuestion" && Array.isArray(input.questions)) {
|
||||||
|
return "question";
|
||||||
|
}
|
||||||
|
return "tool";
|
||||||
|
}
|
||||||
|
|
||||||
export class ClaudeAgentClient implements AgentClient {
|
export class ClaudeAgentClient implements AgentClient {
|
||||||
readonly provider: "claude" = "claude";
|
readonly provider: "claude" = "claude";
|
||||||
readonly capabilities = CLAUDE_CAPABILITIES;
|
readonly capabilities = CLAUDE_CAPABILITIES;
|
||||||
@@ -483,6 +495,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
private lastOptionsModel: string | null = null;
|
private lastOptionsModel: string | null = null;
|
||||||
private activeSidechains = new Map<string, string>();
|
private activeSidechains = new Map<string, string>();
|
||||||
private compacting = false;
|
private compacting = false;
|
||||||
|
private queryRestartNeeded = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
config: ClaudeAgentConfig,
|
config: ClaudeAgentConfig,
|
||||||
@@ -722,37 +735,16 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
? thinkingOptionId
|
? thinkingOptionId
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const query = await this.ensureQuery();
|
|
||||||
|
|
||||||
if (!normalizedThinkingOptionId || normalizedThinkingOptionId === "default") {
|
if (!normalizedThinkingOptionId || normalizedThinkingOptionId === "default") {
|
||||||
// Claude Code TUI exposes only ON/OFF. Default to OFF.
|
|
||||||
try {
|
|
||||||
await query.setMaxThinkingTokens(0);
|
|
||||||
} catch {
|
|
||||||
await query.setMaxThinkingTokens(1);
|
|
||||||
}
|
|
||||||
this.config.thinkingOptionId = undefined;
|
this.config.thinkingOptionId = undefined;
|
||||||
return;
|
} else if (normalizedThinkingOptionId === "on") {
|
||||||
}
|
this.config.thinkingOptionId = "on";
|
||||||
|
} else if (normalizedThinkingOptionId === "off") {
|
||||||
if (normalizedThinkingOptionId === "on") {
|
|
||||||
await query.setMaxThinkingTokens(null);
|
|
||||||
this.config.thinkingOptionId = normalizedThinkingOptionId;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalizedThinkingOptionId === "off") {
|
|
||||||
try {
|
|
||||||
await query.setMaxThinkingTokens(0);
|
|
||||||
} catch {
|
|
||||||
// Some runtimes may reject 0; use a tiny cap as "off".
|
|
||||||
await query.setMaxThinkingTokens(1);
|
|
||||||
}
|
|
||||||
this.config.thinkingOptionId = "off";
|
this.config.thinkingOptionId = "off";
|
||||||
return;
|
} else {
|
||||||
|
throw new Error(`Unknown thinking option: ${normalizedThinkingOptionId}`);
|
||||||
}
|
}
|
||||||
|
this.queryRestartNeeded = true;
|
||||||
throw new Error(`Unknown thinking option: ${normalizedThinkingOptionId}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getPendingPermissions(): AgentPermissionRequest[] {
|
getPendingPermissions(): AgentPermissionRequest[] {
|
||||||
@@ -874,10 +866,18 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async ensureQuery(): Promise<Query> {
|
private async ensureQuery(): Promise<Query> {
|
||||||
if (this.query) {
|
if (this.query && !this.queryRestartNeeded) {
|
||||||
return this.query;
|
return this.query;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.queryRestartNeeded && this.query) {
|
||||||
|
this.input?.end();
|
||||||
|
try { await this.query.return?.(); } catch { /* ignore */ }
|
||||||
|
this.query = null;
|
||||||
|
this.input = null;
|
||||||
|
this.queryRestartNeeded = false;
|
||||||
|
}
|
||||||
|
|
||||||
const input = new Pushable<SDKUserMessage>();
|
const input = new Pushable<SDKUserMessage>();
|
||||||
const options = this.buildOptions();
|
const options = this.buildOptions();
|
||||||
this.logger.debug({ options }, "claude query");
|
this.logger.debug({ options }, "claude query");
|
||||||
@@ -894,11 +894,10 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
? configuredThinkingOptionId
|
? configuredThinkingOptionId
|
||||||
: "off";
|
: "off";
|
||||||
let maxThinkingTokens: number | undefined;
|
let maxThinkingTokens: number | undefined;
|
||||||
if (typeof thinkingOptionId === "string" && thinkingOptionId.length > 0) {
|
if (thinkingOptionId === "on") {
|
||||||
if (thinkingOptionId === "off") {
|
maxThinkingTokens = 10000;
|
||||||
maxThinkingTokens = 0;
|
} else if (thinkingOptionId === "off") {
|
||||||
}
|
maxThinkingTokens = 0;
|
||||||
// For "on" we omit maxThinkingTokens (SDK default max).
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const appendedSystemPrompt = [
|
const appendedSystemPrompt = [
|
||||||
@@ -1117,6 +1116,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
this.logger.info({ durationMs: Date.now() - t1 }, "interruptActiveTurn: query.return() returned");
|
this.logger.info({ durationMs: Date.now() - t1 }, "interruptActiveTurn: query.return() returned");
|
||||||
this.query = null;
|
this.query = null;
|
||||||
this.input = null;
|
this.input = null;
|
||||||
|
this.queryRestartNeeded = false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn({ err: error }, "Failed to interrupt active turn");
|
this.logger.warn({ err: error }, "Failed to interrupt active turn");
|
||||||
}
|
}
|
||||||
@@ -1361,7 +1361,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
id: requestId,
|
id: requestId,
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
name: toolName,
|
name: toolName,
|
||||||
kind: toolName === "ExitPlanMode" ? "plan" : "tool",
|
kind: resolvePermissionKind(toolName, input),
|
||||||
input,
|
input,
|
||||||
suggestions: options.suggestions?.map((suggestion) => ({ ...suggestion })),
|
suggestions: options.suggestions?.map((suggestion) => ({ ...suggestion })),
|
||||||
metadata: Object.keys(metadata).length ? metadata : undefined,
|
metadata: Object.keys(metadata).length ? metadata : undefined,
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export const AgentPermissionRequestPayloadSchema: z.ZodType<AgentPermissionReque
|
|||||||
id: z.string(),
|
id: z.string(),
|
||||||
provider: AgentProviderSchema,
|
provider: AgentProviderSchema,
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
kind: z.enum(["tool", "plan", "mode", "other"]),
|
kind: z.enum(["tool", "plan", "question", "mode", "other"]),
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
input: z.record(z.unknown()).optional(),
|
input: z.record(z.unknown()).optional(),
|
||||||
|
|||||||
Reference in New Issue
Block a user