@yuangc/paseo-plugin-workspace-overview 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/client/agent-preview/PreviewActions.tsx +56 -0
  2. package/client/agent-preview/useAutoCommit.ts +91 -0
  3. package/client/components/AgentCommandMenu.tsx +51 -0
  4. package/client/components/AgentConversationPreview.tsx +620 -0
  5. package/client/components/AgentConversationTimeline.tsx +177 -0
  6. package/client/components/AgentCreatorPanel.tsx +152 -0
  7. package/client/components/AgentQuestionCard.tsx +244 -0
  8. package/client/components/AgentRow.tsx +108 -0
  9. package/client/components/BadgeWithTooltip.tsx +119 -0
  10. package/client/components/BranchRow.tsx +121 -0
  11. package/client/components/ControlGlyph.tsx +18 -0
  12. package/client/components/HeatmapCalendar.tsx +165 -0
  13. package/client/components/ModelBrowserMenu.tsx +135 -0
  14. package/client/components/PaseoAssistantMessage.tsx +216 -0
  15. package/client/components/PaseoShellDetail.tsx +128 -0
  16. package/client/components/PaseoStreamBadge.tsx +94 -0
  17. package/client/components/PaseoTimelineItem.tsx +147 -0
  18. package/client/components/PaseoToolCallGroup.tsx +52 -0
  19. package/client/components/PaseoToolDetail.tsx +55 -0
  20. package/client/components/ProjectCard.tsx +196 -0
  21. package/client/components/ProjectIcon.tsx +49 -0
  22. package/client/components/ProviderBrandIcon.tsx +52 -0
  23. package/client/components/ResizeHandle.tsx +91 -0
  24. package/client/components/SharedComposerInput.tsx +383 -0
  25. package/client/components/Tooltip.tsx +49 -0
  26. package/client/components/WorkspaceAgentRow.tsx +93 -0
  27. package/client/components/WorkspaceBadge.tsx +39 -0
  28. package/client/components/WorkspaceCreateComposer.tsx +170 -0
  29. package/client/components/WorkspaceCreatorPanel.tsx +256 -0
  30. package/client/components/WorkspaceLaunchMenu.tsx +76 -0
  31. package/client/components/WorkspaceMenu.tsx +116 -0
  32. package/client/components/WorkspaceSelectControl.tsx +52 -0
  33. package/client/components/agent-conversation-types.ts +7 -0
  34. package/client/components/workspace-creator-styles.ts +322 -0
  35. package/client/hooks/useAgentActivityHeatmap.ts +178 -0
  36. package/client/hooks/useFilter.ts +107 -0
  37. package/client/hooks/useKeyboardInset.ts +37 -0
  38. package/client/hooks/useResizableWidth.ts +70 -0
  39. package/client/hooks/useWorkspaces.ts +128 -0
  40. package/client/overview/useProjectOptions.ts +73 -0
  41. package/client/web.ts +160 -0
  42. package/client/workspace-agents.tsx +71 -0
  43. package/client/workspace-creator/badge-styles.ts +13 -0
  44. package/client/workspace-creator/constants.ts +14 -0
  45. package/client/workspace-creator/control-styles.ts +12 -0
  46. package/client/workspace-creator/provider-selection.ts +51 -0
  47. package/client/workspace-creator/submit-workspace.ts +67 -0
  48. package/client/workspace-creator/types.ts +40 -0
  49. package/client/workspace-creator/ui-utils.ts +15 -0
  50. package/client/workspace-creator/useProviderCatalog.ts +89 -0
  51. package/client/workspace-overview.tsx +533 -0
  52. package/index.client.tsx +36 -0
  53. package/index.server.ts +33 -0
  54. package/package.json +28 -4
  55. package/paseo-plugin.json +4 -0
  56. package/server/agent-config-impl.ts +57 -0
  57. package/server/auto-commit-impl.ts +221 -0
  58. package/server/git-branches-impl.ts +81 -0
  59. package/server/project-icon-impl.ts +102 -0
  60. package/server/terminal-launch-impl.ts +56 -0
  61. package/shared/agent-activity.ts +16 -0
  62. package/shared/agent-config.ts +30 -0
  63. package/shared/auto-commit.ts +38 -0
  64. package/shared/git-branches.ts +18 -0
  65. package/shared/overview-types.ts +87 -0
  66. package/shared/project-icon.ts +13 -0
  67. package/shared/terminal-launch.ts +20 -0
  68. package/README.md +0 -3
@@ -0,0 +1,177 @@
1
+ import React, { useEffect, useRef, useState } from "react";
2
+ import { ActivityIndicator, AppState, AppStateStatus, Pressable, ScrollView, Text, View } from "react-native";
3
+ import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
4
+ import { Icon } from "@getpaseo/plugin/client/react-native";
5
+ import type { AgentTimelineEntry } from "./agent-conversation-types";
6
+ import { PaseoTimelineItem } from "./PaseoTimelineItem";
7
+ import { PaseoToolCallGroup } from "./PaseoToolCallGroup";
8
+ import { AgentPermissionCard } from "./AgentQuestionCard";
9
+ import type { AgentCapabilityFlags, AgentPermissionRequest, AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
10
+
11
+ type TimelineBlock =
12
+ | { kind: "entry"; entry: AgentTimelineEntry }
13
+ | { kind: "tools"; entries: AgentTimelineEntry[] };
14
+
15
+ function timelineBlocks(entries: AgentTimelineEntry[]): TimelineBlock[] {
16
+ const blocks: TimelineBlock[] = [];
17
+ let tools: AgentTimelineEntry[] = [];
18
+ const flushTools = () => {
19
+ if (tools.length) blocks.push({ kind: "tools", entries: tools });
20
+ tools = [];
21
+ };
22
+ for (const entry of entries) {
23
+ const name = String(entry.item.name ?? "").toLowerCase();
24
+ const groupable = entry.item.type === "tool_call" && name !== "speak" && entry.item.detail?.type !== "plan";
25
+ if (groupable) tools.push(entry);
26
+ else { flushTools(); blocks.push({ kind: "entry", entry }); }
27
+ }
28
+ flushTools();
29
+ return blocks;
30
+ }
31
+
32
+ function assistantTiming(entries: AgentTimelineEntry[], entry: AgentTimelineEntry): { durationMs?: number; completedAt?: string } {
33
+ if (entry.item.type !== "assistant_message") return {};
34
+ const index = entries.findIndex((candidate) => candidate.seqStart === entry.seqStart);
35
+ if (index < 0) return {};
36
+ for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
37
+ const candidate = entries[cursor];
38
+ if (entry.turnId && candidate.turnId && candidate.turnId !== entry.turnId) continue;
39
+ if (candidate.item.type !== "user_message") continue;
40
+ const startedAt = Date.parse(candidate.timestamp);
41
+ const completedAt = Date.parse(entry.timestamp);
42
+ if (!Number.isFinite(startedAt) || !Number.isFinite(completedAt)) return {};
43
+ return { durationMs: Math.max(0, completedAt - startedAt), completedAt: entry.timestamp };
44
+ }
45
+ return {};
46
+ }
47
+
48
+ function RunningTurnIndicator({ startedAt, color }: { startedAt: string | null; color: string }) {
49
+ const [now, setNow] = useState(Date.now());
50
+ const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
51
+ const appStateRef = useRef<AppStateStatus>(AppState.currentState);
52
+
53
+ useEffect(() => {
54
+ timerRef.current = setInterval(() => setNow(Date.now()), 1000);
55
+ return () => {
56
+ if (timerRef.current) clearInterval(timerRef.current);
57
+ timerRef.current = null;
58
+ };
59
+ }, []);
60
+
61
+ useEffect(() => {
62
+ const handleAppState = (nextState: AppStateStatus) => {
63
+ const prev = appStateRef.current;
64
+ appStateRef.current = nextState;
65
+ if (prev !== "active" && nextState === "active") {
66
+ if (!timerRef.current) timerRef.current = setInterval(() => setNow(Date.now()), 1000);
67
+ } else if (prev === "active" && nextState !== "active") {
68
+ if (timerRef.current) {
69
+ clearInterval(timerRef.current);
70
+ timerRef.current = null;
71
+ }
72
+ }
73
+ };
74
+ const subscription = AppState.addEventListener("change", handleAppState);
75
+ return () => {
76
+ subscription.remove();
77
+ if (timerRef.current) {
78
+ clearInterval(timerRef.current);
79
+ timerRef.current = null;
80
+ }
81
+ };
82
+ }, []);
83
+
84
+ const elapsedSeconds = startedAt ? Math.max(0, Math.floor((now - Date.parse(startedAt)) / 1000)) : null;
85
+ return <View accessibilityLabel={elapsedSeconds == null ? "Agent 正在工作" : `Agent 已工作 ${elapsedSeconds} 秒`} style={{ flexDirection: "row", alignItems: "center", gap: 8, minHeight: 32, marginTop: 8 }}>
86
+ <ActivityIndicator size="small" color={color} />
87
+ {elapsedSeconds != null ? <Text style={{ color, fontSize: 11 }}>Working for {elapsedSeconds}s</Text> : null}
88
+ </View>;
89
+ }
90
+
91
+ export function AgentConversationTimeline({ entries, hasOlder, loadingOlder, onLoadOlder, scrollToEndVersion, isRunning, activeTurnStartedAt, pendingPermissions, respondingRequestId, onRespond, rewindCapabilities, rewindingMessageId, onRewind, theme, compact }: {
92
+ entries: AgentTimelineEntry[];
93
+ hasOlder: boolean;
94
+ loadingOlder: boolean;
95
+ onLoadOlder: () => void;
96
+ scrollToEndVersion: number;
97
+ isRunning: boolean;
98
+ activeTurnStartedAt: string | null;
99
+ pendingPermissions: AgentPermissionRequest[];
100
+ respondingRequestId: string | null;
101
+ onRespond: (requestId: string, response: AgentPermissionResponse) => void;
102
+ rewindCapabilities?: AgentCapabilityFlags | null;
103
+ rewindingMessageId: string | null;
104
+ onRewind: (messageId: string, text: string, mode: "conversation" | "files" | "both") => void;
105
+ theme: PluginSurfaceProps["theme"];
106
+ compact: boolean;
107
+ }) {
108
+ const scrollRef = useRef<ScrollView | null>(null);
109
+ const isAtBottomRef = useRef(true);
110
+ const [isAtBottom, setIsAtBottom] = useState(true);
111
+ const [hasNewContent, setHasNewContent] = useState(false);
112
+ useEffect(() => {
113
+ if (!isAtBottomRef.current) {
114
+ setHasNewContent(true);
115
+ return;
116
+ }
117
+ const timer = setTimeout(() => scrollRef.current?.scrollToEnd({ animated: false }), 0);
118
+ return () => clearTimeout(timer);
119
+ }, [pendingPermissions.length, scrollToEndVersion]);
120
+ const scrollToBottom = () => {
121
+ scrollRef.current?.scrollToEnd({ animated: true });
122
+ isAtBottomRef.current = true;
123
+ setIsAtBottom(true);
124
+ setHasNewContent(false);
125
+ };
126
+ return (
127
+ <View style={{ flex: 1 }}>
128
+ <ScrollView
129
+ ref={scrollRef}
130
+ style={{ flex: 1 }}
131
+ scrollEventThrottle={50}
132
+ onScroll={({ nativeEvent }) => {
133
+ const bottomDistance = nativeEvent.contentSize.height - nativeEvent.layoutMeasurement.height - nativeEvent.contentOffset.y;
134
+ const nextAtBottom = bottomDistance <= 48;
135
+ isAtBottomRef.current = nextAtBottom;
136
+ setIsAtBottom(nextAtBottom);
137
+ if (nextAtBottom) setHasNewContent(false);
138
+ }}
139
+ contentContainerStyle={{ paddingHorizontal: 24, paddingTop: 14, paddingBottom: 24 }}
140
+ >
141
+ <View>
142
+ {hasOlder ? <Pressable
143
+ accessibilityRole="button"
144
+ accessibilityLabel="加载更早消息"
145
+ disabled={loadingOlder}
146
+ onPress={onLoadOlder}
147
+ style={{ alignSelf: "center", minHeight: 32, paddingHorizontal: 12, marginBottom: 12, borderRadius: 6, borderWidth: 1, borderColor: theme.colors.border, alignItems: "center", justifyContent: "center", opacity: loadingOlder ? 0.65 : 1 }}
148
+ >
149
+ {loadingOlder ? <ActivityIndicator size="small" color={theme.colors.foregroundMuted} /> : <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>加载更早消息</Text>}
150
+ </Pressable> : null}
151
+ {timelineBlocks(entries).map((block) => block.kind === "tools"
152
+ ? <PaseoToolCallGroup key={`tools-${block.entries[0]?.seqStart}`} entries={block.entries} theme={theme} />
153
+ : <PaseoTimelineItem key={`${block.entry.seqStart}:${block.entry.item.type}`} entry={block.entry} theme={theme} rewindCapabilities={rewindCapabilities} rewinding={rewindingMessageId === block.entry.item.messageId} onRewind={onRewind} {...assistantTiming(entries, block.entry)} />)}
154
+ {pendingPermissions.length > 0 ? <View style={{ gap: 8 }}>
155
+ {pendingPermissions.map((request) => (
156
+ <AgentPermissionCard
157
+ key={request.id}
158
+ request={request}
159
+ theme={theme}
160
+ compact={compact}
161
+ responding={respondingRequestId === request.id}
162
+ onRespond={(response) => onRespond(request.id, response)}
163
+ />
164
+ ))}
165
+ </View> : null}
166
+ {isRunning && pendingPermissions.length === 0 ? <RunningTurnIndicator startedAt={activeTurnStartedAt} color={theme.colors.foregroundMuted} /> : null}
167
+ </View>
168
+ </ScrollView>
169
+ {!isAtBottom ? <Pressable
170
+ accessibilityRole="button"
171
+ accessibilityLabel={hasNewContent ? "有新消息,回到底部" : "回到底部"}
172
+ onPress={scrollToBottom}
173
+ style={{ position: "absolute", bottom: 12, alignSelf: "center", width: 36, height: 36, borderRadius: 18, borderWidth: 1, borderColor: theme.colors.border, backgroundColor: theme.colors.surface1, alignItems: "center", justifyContent: "center", shadowColor: "#000", shadowOpacity: 0.18, shadowRadius: 8, shadowOffset: { width: 0, height: 3 }, elevation: 4 }}
174
+ ><Icon name="ChevronDown" size={20} color={theme.colors.foreground} /></Pressable> : null}
175
+ </View>
176
+ );
177
+ }
@@ -0,0 +1,152 @@
1
+ import React, { useCallback, useMemo, useState } from "react";
2
+ import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
3
+ import { Icon } from "@getpaseo/plugin/client/react-native";
4
+ import { Pressable, Text, View, type TextStyle, type ViewStyle } from "react-native";
5
+
6
+ import type { WorkspaceEntry } from "../../shared/overview-types";
7
+ import { providerModelId, buildSelection, providerById, selectableModels } from "../workspace-creator/provider-selection";
8
+ import type { OpenMenu, PaseoClient } from "../workspace-creator/types";
9
+ import { useProviderCatalog } from "../workspace-creator/useProviderCatalog";
10
+ import { SharedComposerInput } from "./SharedComposerInput";
11
+ import { ResizeHandle } from "./ResizeHandle";
12
+ import { useKeyboardInset } from "../hooks/useKeyboardInset";
13
+ import { ensureSelection } from "../workspace-creator/submit-workspace";
14
+ import type { PastedImage } from "../web";
15
+
16
+ interface AgentCreatorPanelProps {
17
+ workspace: WorkspaceEntry;
18
+ paseo: PaseoClient;
19
+ theme: PluginSurfaceProps["theme"];
20
+ compact: boolean;
21
+ initialPanelWidth?: number;
22
+ maxPanelWidth?: number;
23
+ onClose: () => void;
24
+ onCreated: (agent: { id: string }) => void;
25
+ }
26
+
27
+ export function AgentCreatorPanel({ workspace, paseo, theme, compact, initialPanelWidth = 480, maxPanelWidth = 800, onClose, onCreated }: AgentCreatorPanelProps) {
28
+ const [prompt, setPrompt] = useState("");
29
+ const [pending, setPending] = useState(false);
30
+ const [error, setError] = useState<string | null>(null);
31
+ const [openMenu, setOpenMenu] = useState<OpenMenu>(null);
32
+ const [images, setImages] = useState<PastedImage[]>([]);
33
+ const keyboardInset = useKeyboardInset(compact);
34
+ const { snapshot, selection, setSelection, loading, error: providerError } = useProviderCatalog(
35
+ paseo,
36
+ workspace.workspaceDirectory,
37
+ );
38
+ const branchLabel = workspace.gitRuntime?.currentBranch?.replace(/^heads\//, "") ?? workspace.name;
39
+
40
+ const selectModel = useCallback((combinedId: string) => {
41
+ const separator = combinedId.indexOf("::");
42
+ const providerId = separator >= 0 ? combinedId.slice(0, separator) : selection?.providerId ?? "";
43
+ const modelId = separator >= 0 ? combinedId.slice(separator + 2) : combinedId;
44
+ const entry = providerById(snapshot, providerId);
45
+ const model = selectableModels(entry).find((item) => item.id === modelId);
46
+ if (entry && model) setSelection(buildSelection(entry, model));
47
+ setOpenMenu(null);
48
+ }, [selection?.providerId, setSelection, snapshot]);
49
+
50
+ const selectMode = useCallback((modeId: string) => {
51
+ const entry = providerById(snapshot, selection?.providerId ?? "");
52
+ const mode = entry?.modes?.find((item) => item.id === modeId);
53
+ setSelection((current) => current ? { ...current, modeId, modeLabel: mode?.label ?? modeId } : current);
54
+ setOpenMenu(null);
55
+ }, [selection?.providerId, setSelection, snapshot]);
56
+
57
+ const selectThinking = useCallback((thinkingOptionId: string) => {
58
+ const entry = providerById(snapshot, selection?.providerId ?? "");
59
+ const model = selectableModels(entry).find((item) => item.id === selection?.modelId);
60
+ const thinking = model?.thinkingOptions?.find((item) => item.id === thinkingOptionId);
61
+ setSelection((current) => current ? { ...current, thinkingOptionId, thinkingLabel: thinking?.label ?? thinkingOptionId } : current);
62
+ setOpenMenu(null);
63
+ }, [selection?.modelId, selection?.providerId, setSelection, snapshot]);
64
+
65
+ const submit = useCallback(async () => {
66
+ const nextPrompt = prompt.trim();
67
+ if (!nextPrompt && images.length === 0) return;
68
+ setPending(true);
69
+ setError(null);
70
+ try {
71
+ const resolvedSelection = await ensureSelection({
72
+ paseo,
73
+ projectDirectory: workspace.workspaceDirectory,
74
+ snapshot,
75
+ selection,
76
+ });
77
+ setSelection(resolvedSelection);
78
+ const agent = await paseo.workspaces.ref(workspace.id).agents.create({
79
+ config: {
80
+ provider: providerModelId(resolvedSelection),
81
+ ...(resolvedSelection.modeId ? { modeId: resolvedSelection.modeId } : {}),
82
+ ...(resolvedSelection.thinkingOptionId ? { thinkingOptionId: resolvedSelection.thinkingOptionId } : {}),
83
+ },
84
+ prompt: nextPrompt,
85
+ images: images.map(({ data, mimeType }) => ({ data, mimeType })),
86
+ });
87
+ setPrompt("");
88
+ setImages([]);
89
+ onCreated(agent);
90
+ } catch (submitError) {
91
+ setError(submitError instanceof Error ? submitError.message : String(submitError));
92
+ } finally {
93
+ setPending(false);
94
+ }
95
+ }, [images, onCreated, paseo, prompt, selection, setSelection, snapshot, workspace.id, workspace.workspaceDirectory]);
96
+
97
+ const styles = useMemo(() => ({
98
+ panel: { height: "100%", borderLeftWidth: 1, borderLeftColor: theme.colors.foregroundMuted + "22", backgroundColor: theme.colors.surface0 } as ViewStyle,
99
+ header: { height: 54, paddingHorizontal: 14, flexDirection: "row", alignItems: "center", gap: 8, borderBottomWidth: 1, borderBottomColor: theme.colors.foregroundMuted + "22" } as ViewStyle,
100
+ title: { flex: 1, color: theme.colors.foreground, fontSize: 14, fontWeight: "600" } as TextStyle,
101
+ subtitle: { color: theme.colors.foregroundMuted, fontSize: 11 } as TextStyle,
102
+ close: { width: 30, height: 30, alignItems: "center", justifyContent: "center" } as ViewStyle,
103
+ body: { flex: 1, justifyContent: "flex-end", paddingHorizontal: compact ? 12 : 20, paddingBottom: compact ? 12 + keyboardInset : 20 } as ViewStyle,
104
+ error: { color: (theme.colors as { statusDanger?: string }).statusDanger ?? "#ef4444", fontSize: 12, marginTop: 8 } as TextStyle,
105
+ }), [compact, keyboardInset, theme]);
106
+
107
+ const content = <>
108
+ <View style={styles.header}>
109
+ <Icon name="Plus" size={17} color={theme.colors.foregroundMuted} />
110
+ <View style={{ flex: 1, minWidth: 0 }}>
111
+ <Text numberOfLines={1} style={styles.title}>新建 Agent</Text>
112
+ <Text numberOfLines={1} style={styles.subtitle}>{branchLabel}</Text>
113
+ </View>
114
+ <Pressable accessibilityLabel="关闭新建 Agent" disabled={pending} onPress={onClose} style={styles.close}>
115
+ <Icon name="X" size={16} color={theme.colors.foregroundMuted} />
116
+ </Pressable>
117
+ </View>
118
+ <View style={styles.body}>
119
+ <SharedComposerInput
120
+ selection={selection}
121
+ snapshot={snapshot}
122
+ providerLoading={loading}
123
+ openMenu={openMenu}
124
+ onToggleMenu={setOpenMenu}
125
+ onSelectModel={selectModel}
126
+ onSelectMode={selectMode}
127
+ onSelectThinking={selectThinking}
128
+ isAgentRunning={false}
129
+ isCancelling={false}
130
+ onCancel={() => {}}
131
+ placeholder="给新 Agent 发第一条消息"
132
+ theme={theme}
133
+ disabled={pending}
134
+ autoFocus
135
+ inputNativeID="agent-create-prompt"
136
+ pendingLabel={pending ? "Creating" : null}
137
+ value={prompt}
138
+ onChangeText={setPrompt}
139
+ onSubmit={submit}
140
+ images={images}
141
+ onImagesChange={setImages}
142
+ onPasteError={setError}
143
+ />
144
+ {error ?? providerError ? <Text style={styles.error}>{error ?? providerError}</Text> : null}
145
+ </View>
146
+ </>;
147
+
148
+ if (compact) {
149
+ return <View style={[styles.panel, { flex: 1, width: "100%" }]}>{content}</View>;
150
+ }
151
+ return <ResizeHandle theme={theme} side="left" variant="grip" initialWidth={initialPanelWidth} minWidth={360} maxWidth={maxPanelWidth} style={styles.panel}>{content}</ResizeHandle>;
152
+ }
@@ -0,0 +1,244 @@
1
+ import React, { useCallback, useMemo, useState } from "react";
2
+ import { ActivityIndicator, Pressable, ScrollView, Text, TextInput, View, type PressableStateCallbackType, type TextStyle, type ViewStyle } from "react-native";
3
+ import { Icon } from "@getpaseo/plugin/client/react-native";
4
+ import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
5
+ import type { AgentPermissionRequest, AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
6
+ import { PaseoAssistantMessage } from "./PaseoAssistantMessage";
7
+
8
+ interface QuestionOption { label: string; description?: string }
9
+ interface QuestionFormQuestion {
10
+ question: string; header: string; options: QuestionOption[]; multiSelect: boolean;
11
+ allowOther: boolean; allowEmpty: boolean; placeholder?: string; dismissLabel?: string;
12
+ }
13
+ type Selections = Record<number, ReadonlySet<number>>;
14
+ type OtherTexts = Record<number, string>;
15
+
16
+ function parseQuestions(input: unknown): QuestionFormQuestion[] | null {
17
+ if (typeof input !== "object" || input === null || !("questions" in input) || !Array.isArray((input as Record<string, unknown>).questions)) return null;
18
+ const questions: QuestionFormQuestion[] = [];
19
+ for (const raw of (input as Record<string, unknown>).questions as unknown[]) {
20
+ if (typeof raw !== "object" || raw === null) return null;
21
+ const question = raw as Record<string, unknown>;
22
+ if (typeof question.question !== "string" || typeof question.header !== "string" || !Array.isArray(question.options)) return null;
23
+ const options: QuestionOption[] = [];
24
+ for (const rawOption of question.options) {
25
+ if (typeof rawOption !== "object" || rawOption === null || typeof (rawOption as Record<string, unknown>).label !== "string") return null;
26
+ const option = rawOption as Record<string, unknown>;
27
+ options.push({ label: option.label as string, description: typeof option.description === "string" ? option.description : undefined });
28
+ }
29
+ questions.push({
30
+ question: question.question, header: question.header, options,
31
+ multiSelect: question.multiSelect === true,
32
+ allowOther: question.allowOther === true || question.isOther === true,
33
+ allowEmpty: question.allowEmpty === true,
34
+ placeholder: typeof question.placeholder === "string" ? question.placeholder : undefined,
35
+ dismissLabel: typeof question.dismissLabel === "string" ? question.dismissLabel : undefined,
36
+ });
37
+ }
38
+ return questions.length > 0 ? questions : null;
39
+ }
40
+
41
+ function showsTextInput(question: QuestionFormQuestion): boolean { return question.options.length === 0 || question.allowOther; }
42
+ function isAnswered(question: QuestionFormQuestion, index: number, selections: Selections, otherTexts: OtherTexts): boolean {
43
+ if (selections[index]?.size) return true;
44
+ if (!showsTextInput(question)) return false;
45
+ return Boolean(otherTexts[index]?.trim()) || question.allowEmpty;
46
+ }
47
+ function buildAnswers(questions: QuestionFormQuestion[], selections: Selections, otherTexts: OtherTexts): Record<string, string> {
48
+ const answers: Record<string, string> = {};
49
+ questions.forEach((question, index) => {
50
+ const otherText = otherTexts[index]?.trim();
51
+ if (showsTextInput(question) && otherText) answers[question.header] = otherText;
52
+ else if (question.allowEmpty && question.options.length === 0) answers[question.header] = "";
53
+ else if (selections[index]?.size) answers[question.header] = Array.from(selections[index]).map((optionIndex) => question.options[optionIndex].label).join(", ");
54
+ });
55
+ return answers;
56
+ }
57
+
58
+ function QuestionFormCard({ request, theme, compact, responding, onRespond }: {
59
+ request: AgentPermissionRequest; theme: PluginSurfaceProps["theme"]; compact: boolean; responding: boolean;
60
+ onRespond: (response: AgentPermissionResponse) => void;
61
+ }) {
62
+ const questions = useMemo(() => parseQuestions(request.input), [request.input]);
63
+ const [selections, setSelections] = useState<Record<number, Set<number>>>({});
64
+ const [otherTexts, setOtherTexts] = useState<Record<number, string>>({});
65
+ const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null);
66
+ const [activeIndex, setActiveIndex] = useState(0);
67
+ const styles = useMemo(() => createStyles(theme), [theme]);
68
+ const borderAccent = (theme.colors as typeof theme.colors & { borderAccent?: string }).borderAccent ?? theme.colors.border;
69
+
70
+ const toggleOption = useCallback((questionIndex: number, optionIndex: number, multiSelect: boolean) => {
71
+ const current = selections[questionIndex] ?? new Set<number>();
72
+ const next = new Set(current);
73
+ if (multiSelect) { if (next.has(optionIndex)) next.delete(optionIndex); else next.add(optionIndex); }
74
+ else if (next.has(optionIndex)) next.clear();
75
+ else { next.clear(); next.add(optionIndex); }
76
+ setSelections((previous) => ({ ...previous, [questionIndex]: next }));
77
+ setOtherTexts((previous) => { if (!previous[questionIndex]) return previous; const nextTexts = { ...previous }; delete nextTexts[questionIndex]; return nextTexts; });
78
+ if (!multiSelect && next.size > 0 && questionIndex === activeIndex && questions) setActiveIndex(Math.min(questionIndex + 1, questions.length - 1));
79
+ }, [activeIndex, questions, selections]);
80
+ const setOtherText = useCallback((questionIndex: number, text: string) => {
81
+ setOtherTexts((previous) => ({ ...previous, [questionIndex]: text }));
82
+ if (text.length > 0) setSelections((previous) => previous[questionIndex]?.size ? { ...previous, [questionIndex]: new Set<number>() } : previous);
83
+ }, []);
84
+
85
+ if (!questions) return null;
86
+ const resolvedIndex = Math.min(activeIndex, questions.length - 1);
87
+ const activeQuestion = questions[resolvedIndex];
88
+ const activeAnswered = isAnswered(activeQuestion, resolvedIndex, selections, otherTexts);
89
+ const allAnswered = questions.every((question, index) => isAnswered(question, index, selections, otherTexts));
90
+ const isLastQuestion = resolvedIndex === questions.length - 1;
91
+ const primaryDisabled = responding || (isLastQuestion ? !allAnswered : !activeAnswered);
92
+ const selected = selections[resolvedIndex] ?? new Set<number>();
93
+ const submit = () => {
94
+ if (!allAnswered || responding) return;
95
+ setRespondingAction("submit");
96
+ onRespond({ behavior: "allow", updatedInput: { ...request.input, answers: buildAnswers(questions, selections, otherTexts) } });
97
+ };
98
+ const primaryAction = () => {
99
+ if (!isLastQuestion) { if (activeAnswered && !responding) setActiveIndex((index) => Math.min(index + 1, questions.length - 1)); return; }
100
+ submit();
101
+ };
102
+ const dismissLabel = questions.find((question) => question.dismissLabel)?.dismissLabel ?? "关闭";
103
+ const dismiss = () => {
104
+ setRespondingAction("dismiss");
105
+ if (questions.every((question) => question.allowEmpty && question.options.length === 0)) onRespond({ behavior: "allow", updatedInput: { ...request.input, answers: buildAnswers(questions, selections, otherTexts) } });
106
+ else onRespond({ behavior: "deny", message: "Dismissed by user" });
107
+ };
108
+
109
+ return <View style={styles.container} testID="question-form-card">
110
+ {questions.length > 1 ? <View style={styles.questionNav} accessibilityRole="tablist" testID="question-form-question-nav">
111
+ {questions.map((question, index) => {
112
+ const active = index === resolvedIndex;
113
+ return <Pressable key={question.header} accessibilityRole="tab" accessibilityLabel={`Question ${index + 1} of ${questions.length}`} accessibilityState={{ selected: active }} disabled={responding} onPress={() => setActiveIndex(index)} style={({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [styles.questionNavButton, { backgroundColor: active || hovered ? theme.colors.surface2 : theme.colors.surface1, borderColor: active ? theme.colors.foregroundMuted : theme.colors.border }, pressed && styles.pressed]}>
114
+ {isAnswered(question, index, selections, otherTexts) ? <Icon name="Check" size={12} color={active ? theme.colors.foreground : theme.colors.foregroundMuted} /> : null}
115
+ <Text numberOfLines={1} style={[styles.questionNavText, { color: active ? theme.colors.foreground : theme.colors.foregroundMuted }]}>{question.header}</Text>
116
+ </Pressable>;
117
+ })}
118
+ </View> : null}
119
+ <View style={styles.questionHeader}><Text style={styles.questionText} testID="question-form-current-question">{activeQuestion.question}</Text></View>
120
+ <View key={activeQuestion.question} style={styles.questionBlock}>
121
+ {activeQuestion.options.length > 0 ? <View style={styles.optionsWrap} accessibilityRole={activeQuestion.multiSelect ? undefined : "radiogroup"} accessibilityLabel={activeQuestion.question}>
122
+ {activeQuestion.options.map((option, optionIndex) => {
123
+ const isSelected = selected.has(optionIndex);
124
+ return <Pressable key={option.label} disabled={responding} accessibilityRole={activeQuestion.multiSelect ? "checkbox" : "radio"} accessibilityLabel={option.label} accessibilityState={{ checked: isSelected }} onPress={() => toggleOption(resolvedIndex, optionIndex, activeQuestion.multiSelect)} style={({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [styles.optionItem, (hovered || isSelected) && { backgroundColor: theme.colors.surface2 }, pressed && styles.pressed]}>
125
+ <View style={styles.optionItemContent}>
126
+ <View style={[styles.selectionControl, activeQuestion.multiSelect ? styles.checkbox : styles.radio, { borderColor: isSelected ? theme.colors.accent : theme.colors.foregroundMuted, backgroundColor: isSelected && activeQuestion.multiSelect ? theme.colors.accent : "transparent" }]}>
127
+ {isSelected && activeQuestion.multiSelect ? <Icon name="Check" size={12} color={theme.colors.accentForeground} /> : null}
128
+ {isSelected && !activeQuestion.multiSelect ? <View style={[styles.radioDot, { backgroundColor: theme.colors.accent }]} /> : null}
129
+ </View>
130
+ <View style={styles.optionTextBlock}><Text style={styles.optionLabel}>{option.label}</Text>{option.description ? <Text style={styles.optionDescription}>{option.description}</Text> : null}</View>
131
+ </View>
132
+ </Pressable>;
133
+ })}
134
+ </View> : null}
135
+ {showsTextInput(activeQuestion) ? <TextInput value={otherTexts[resolvedIndex] ?? ""} accessibilityLabel={activeQuestion.question} editable={!responding} blurOnSubmit={false} placeholder={activeQuestion.placeholder ?? (activeQuestion.options.length === 0 ? "输入你的回答..." : "其他...")} placeholderTextColor={theme.colors.foregroundMuted} onChangeText={(text) => setOtherText(resolvedIndex, text)} onSubmitEditing={primaryAction} style={[styles.otherInput, { borderColor: (otherTexts[resolvedIndex]?.length ?? 0) > 0 ? borderAccent : theme.colors.border }]} /> : null}
136
+ </View>
137
+ <View style={[styles.actionsContainer, !compact && styles.actionsContainerDesktop]}>
138
+ <Pressable accessibilityRole="button" accessibilityLabel={dismissLabel} disabled={responding} onPress={dismiss} style={({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [styles.actionButton, { backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1, borderColor: borderAccent }, pressed && styles.pressed]}>
139
+ {respondingAction === "dismiss" ? <ActivityIndicator size="small" color={theme.colors.foregroundMuted} /> : <View style={styles.actionContent}><Icon name="X" size={14} color={theme.colors.foregroundMuted} /><Text style={[styles.actionText, { color: theme.colors.foregroundMuted }]}>{dismissLabel}</Text></View>}
140
+ </Pressable>
141
+ <Pressable accessibilityRole="button" accessibilityLabel={isLastQuestion ? "提交" : "下一步"} disabled={primaryDisabled} onPress={primaryAction} style={({ pressed }) => [styles.actionButton, { backgroundColor: theme.colors.accent, borderColor: theme.colors.accent, opacity: primaryDisabled ? 0.5 : 1 }, pressed && !primaryDisabled && styles.pressed]}>
142
+ {respondingAction === "submit" ? <ActivityIndicator size="small" color={theme.colors.accentForeground} /> : <View style={styles.actionContent}><Icon name="Check" size={14} color={theme.colors.accentForeground} /><Text style={[styles.actionText, { color: theme.colors.accentForeground }]}>{isLastQuestion ? "提交" : "下一步"}</Text></View>}
143
+ </Pressable>
144
+ </View>
145
+ </View>;
146
+ }
147
+
148
+ function detailText(value: unknown): string {
149
+ if (value == null) return "";
150
+ if (typeof value === "string") return value;
151
+ try { return JSON.stringify(value, null, 2); } catch { return String(value); }
152
+ }
153
+
154
+ export function AgentPermissionCard({ request, theme, compact, responding, onRespond }: {
155
+ request: AgentPermissionRequest; theme: PluginSurfaceProps["theme"]; compact: boolean; responding: boolean;
156
+ onRespond: (response: AgentPermissionResponse) => void;
157
+ }) {
158
+ const [respondingActionId, setRespondingActionId] = useState<string | null>(null);
159
+ const styles = useMemo(() => createPermissionStyles(theme), [theme]);
160
+ const borderAccent = (theme.colors as typeof theme.colors & { borderAccent?: string }).borderAccent ?? theme.colors.border;
161
+ if (request.kind === "question") return <QuestionFormCard request={request} theme={theme} compact={compact} responding={responding} onRespond={onRespond} />;
162
+
163
+ const isPlan = request.kind === "plan";
164
+ const planText = typeof request.metadata?.planText === "string" ? request.metadata.planText : typeof request.input?.plan === "string" ? request.input.plan : undefined;
165
+ const title = isPlan ? "Plan" : (request.title ?? request.name ?? "需要权限");
166
+ const actions = request.actions?.length ? request.actions : [
167
+ { id: "reject", label: "拒绝", behavior: "deny" as const, variant: "danger" as const },
168
+ { id: "accept", label: isPlan ? "实施" : "接受", behavior: "allow" as const, variant: "primary" as const },
169
+ ];
170
+ const detail = request.detail ?? { type: "unknown", input: request.input ?? null, output: null };
171
+ const pressAction = (action: (typeof actions)[number]) => {
172
+ setRespondingActionId(action.id);
173
+ onRespond(action.behavior === "allow" ? { behavior: "allow", selectedActionId: action.id } : { behavior: "deny", selectedActionId: action.id, message: "Denied by user" });
174
+ };
175
+ const footer = <>
176
+ <Text style={styles.question}>你想如何继续?</Text>
177
+ <View style={[styles.optionsContainer, !compact && styles.optionsContainerDesktop]}>
178
+ {actions.map((action) => {
179
+ const primary = action.variant === "primary";
180
+ return <Pressable key={action.id} accessibilityRole="button" disabled={responding} onPress={() => pressAction(action)} style={({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [styles.optionButton, { borderColor: borderAccent }, hovered && styles.optionButtonHovered, pressed && styles.pressed]}>
181
+ {respondingActionId === action.id ? <ActivityIndicator size="small" color={primary ? theme.colors.foreground : theme.colors.foregroundMuted} /> : <View style={styles.optionContent}><Icon name={action.behavior === "allow" ? "Check" : "X"} size={14} color={primary ? theme.colors.foreground : theme.colors.foregroundMuted} /><Text style={[styles.optionText, primary && styles.optionTextPrimary]}>{action.label}</Text></View>}
182
+ </Pressable>;
183
+ })}
184
+ </View>
185
+ </>;
186
+
187
+ return <View style={styles.container}>
188
+ <Text style={styles.title}>{title}</Text>
189
+ {request.description ? <Text style={styles.description}>{request.description}</Text> : null}
190
+ {planText ? <View style={styles.section}>{!isPlan ? <Text style={styles.sectionTitle}>建议计划</Text> : null}<PaseoAssistantMessage text={planText} theme={theme} /></View> : null}
191
+ {!isPlan ? <ScrollView style={styles.detail} nestedScrollEnabled><Text selectable style={styles.detailText}>{detailText(detail)}</Text></ScrollView> : null}
192
+ {footer}
193
+ </View>;
194
+ }
195
+
196
+ function createStyles(theme: PluginSurfaceProps["theme"]) {
197
+ return {
198
+ container: { padding: 12, borderRadius: 8, borderWidth: 1, gap: 12, backgroundColor: theme.colors.surface1, borderColor: theme.colors.border } as ViewStyle,
199
+ questionBlock: { gap: 8 } as ViewStyle,
200
+ questionHeader: { flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 12, paddingBottom: 4, flex: 1 } as ViewStyle,
201
+ questionText: { flex: 1, color: theme.colors.foreground, fontSize: 14, fontWeight: "500", lineHeight: 22 } as TextStyle,
202
+ optionsWrap: { gap: 4 } as ViewStyle,
203
+ questionNav: { flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 4, paddingHorizontal: 12 } as ViewStyle,
204
+ questionNavButton: { flexDirection: "row", alignItems: "center", gap: 4, minHeight: 28, paddingHorizontal: 8, paddingVertical: 4, borderRadius: 6, borderWidth: 1 } as ViewStyle,
205
+ questionNavText: { fontSize: 14, fontWeight: "500" } as TextStyle,
206
+ optionItem: { flexDirection: "row", alignItems: "center", paddingHorizontal: 12, paddingVertical: 8, borderRadius: 6 } as ViewStyle,
207
+ pressed: { opacity: 0.9 } as ViewStyle,
208
+ optionItemContent: { flex: 1, flexDirection: "row", alignItems: "flex-start", gap: 8 } as ViewStyle,
209
+ optionTextBlock: { flex: 1, gap: 4 } as ViewStyle,
210
+ optionLabel: { color: theme.colors.foreground, fontSize: 14, fontWeight: "600", lineHeight: 22 } as TextStyle,
211
+ optionDescription: { color: theme.colors.foregroundMuted, fontSize: 14, lineHeight: 20 } as TextStyle,
212
+ selectionControl: { width: 18, height: 18, alignItems: "center", justifyContent: "center", borderWidth: 1, marginTop: 2 } as ViewStyle,
213
+ checkbox: { borderRadius: 4 } as ViewStyle,
214
+ radio: { borderRadius: 999 } as ViewStyle,
215
+ radioDot: { width: 8, height: 8, borderRadius: 999 } as ViewStyle,
216
+ otherInput: { borderWidth: 1, borderRadius: 8, borderColor: theme.colors.border, paddingHorizontal: 12, paddingVertical: 12, fontSize: 14, color: theme.colors.foreground, backgroundColor: theme.colors.surface2 } as TextStyle,
217
+ actionsContainer: { gap: 8 } as ViewStyle,
218
+ actionsContainerDesktop: { flexDirection: "row", justifyContent: "flex-start", alignItems: "center" } as ViewStyle,
219
+ actionButton: { paddingVertical: 8, paddingHorizontal: 12, borderRadius: 6, alignItems: "center", borderWidth: 1 } as ViewStyle,
220
+ actionContent: { flexDirection: "row", alignItems: "center", gap: 8 } as ViewStyle,
221
+ actionText: { fontSize: 14 } as TextStyle,
222
+ };
223
+ }
224
+
225
+ function createPermissionStyles(theme: PluginSurfaceProps["theme"]) {
226
+ return {
227
+ container: { marginVertical: 12, padding: 12, borderRadius: 8, borderWidth: 1, gap: 8, backgroundColor: theme.colors.surface1, borderColor: theme.colors.border } as ViewStyle,
228
+ title: { fontSize: 14, lineHeight: 22, color: theme.colors.foreground } as TextStyle,
229
+ description: { fontSize: 14, lineHeight: 20, color: theme.colors.foregroundMuted } as TextStyle,
230
+ section: { gap: 8 } as ViewStyle,
231
+ sectionTitle: { fontSize: 12, color: theme.colors.foregroundMuted } as TextStyle,
232
+ question: { fontSize: 14, marginTop: 4, marginBottom: 4, color: theme.colors.foregroundMuted } as TextStyle,
233
+ optionsContainer: { gap: 8 } as ViewStyle,
234
+ optionsContainerDesktop: { flexDirection: "row", flexWrap: "wrap", justifyContent: "flex-start", alignItems: "center", width: "100%" } as ViewStyle,
235
+ optionButton: { paddingVertical: 8, paddingHorizontal: 12, borderRadius: 6, alignItems: "center", borderWidth: 1, backgroundColor: theme.colors.surface1 } as ViewStyle,
236
+ optionButtonHovered: { backgroundColor: theme.colors.surface2 } as ViewStyle,
237
+ pressed: { opacity: 0.9 } as ViewStyle,
238
+ optionContent: { flexDirection: "row", alignItems: "center", gap: 8 } as ViewStyle,
239
+ optionText: { fontSize: 14, fontWeight: "normal", color: theme.colors.foregroundMuted } as TextStyle,
240
+ optionTextPrimary: { color: theme.colors.foreground } as TextStyle,
241
+ detail: { maxHeight: 200, borderRadius: 6, backgroundColor: theme.colors.surface2, padding: 10 } as ViewStyle,
242
+ detailText: { color: theme.colors.foreground, fontSize: 12, lineHeight: 18, fontFamily: "monospace" } as TextStyle,
243
+ };
244
+ }