Skip to content

Commit 2414593

Browse files
authored
feat: refactor permissions and question UI (#652)
This PR makes the entire permissions + questinos UI keyboard navigateable + clickable, and just refactors a ton of stuff related to it. It also improves the individual tool call UI per tool call type ![image.png](https://app.graphite.com/user-attachments/assets/ce2f17f6-522f-4db5-ae22-457cf269b477.png) ![image.png](https://app.graphite.com/user-attachments/assets/4a0588fe-fea9-4426-b50b-899d4df23399.png)
1 parent 164f60b commit 2414593

51 files changed

Lines changed: 3177 additions & 1084 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ AGENTS.md
4545
# Playwright
4646
playwright-results/
4747
playwright-report/
48+
.playwright-cli
4849
test-results/
4950

5051
*storybook.log

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,3 +228,15 @@ Native modules (like node-pty) need to be rebuilt for your Electron version:
228228
```bash
229229
pnpm --filter twig exec electron-rebuild
230230
```
231+
232+
## Acknowledgments
233+
234+
Built with love by the PostHog team.
235+
236+
## Roadmap
237+
238+
Stay tuned for upcoming features and improvements.
239+
240+
## FAQ
241+
242+
Check the issues page for common questions and answers.

apps/cli/src/commands/split.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export async function split(
3737
process.exit(1);
3838
}
3939

40-
const { matchingFiles, availableFiles } = previewResult.value;
40+
const { matchingFiles } = previewResult.value;
4141

4242
// Show preview
4343
message(

apps/twig/.storybook/main.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ const config: StorybookConfig = {
3232
"@stores": path.resolve(__dirname, "../src/renderer/stores"),
3333
"@hooks": path.resolve(__dirname, "../src/renderer/hooks"),
3434
"@utils": path.resolve(__dirname, "../src/renderer/utils"),
35+
"@posthog/agent/adapters/claude/permission-options": path.resolve(
36+
__dirname,
37+
"../../../packages/agent/src/adapters/claude/permission-options.ts",
38+
),
3539
},
3640
},
3741
});

apps/twig/src/main/services/agent/schemas.ts

Lines changed: 9 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import type {
2+
RequestPermissionRequest,
3+
PermissionOption as SdkPermissionOption,
4+
} from "@agentclientprotocol/sdk";
15
import { z } from "zod";
26

37
// Session credentials schema
@@ -161,20 +165,8 @@ export interface AgentSessionEventPayload {
161165
payload: unknown;
162166
}
163167

164-
export interface PermissionOption {
165-
kind: "allow_once" | "allow_always" | "reject_once" | "reject_always";
166-
name: string;
167-
optionId: string;
168-
description?: string;
169-
}
170-
171-
export interface PermissionRequestPayload {
172-
sessionId: string;
173-
toolCallId: string;
174-
title: string;
175-
options: PermissionOption[];
176-
rawInput: unknown;
177-
}
168+
export type PermissionOption = SdkPermissionOption;
169+
export type PermissionRequestPayload = RequestPermissionRequest;
178170

179171
export interface AgentServiceEvents {
180172
[AgentServiceEvent.SessionEvent]: AgentSessionEventPayload;
@@ -186,10 +178,10 @@ export const respondToPermissionInput = z.object({
186178
sessionId: z.string(),
187179
toolCallId: z.string(),
188180
optionId: z.string(),
189-
// For multi-select mode: array of selected option IDs
190-
selectedOptionIds: z.array(z.string()).optional(),
191-
// For "Other" option: custom text input from user
181+
// For "Other" option: custom text input from user (ACP extension via _meta)
192182
customInput: z.string().optional(),
183+
// For multi-question flows: all answers keyed by question text
184+
answers: z.record(z.string(), z.string()).optional(),
193185
});
194186

195187
export type RespondToPermissionInput = z.infer<typeof respondToPermissionInput>;

apps/twig/src/main/services/agent/service.ts

Lines changed: 8 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -253,8 +253,8 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
253253
sessionId: string,
254254
toolCallId: string,
255255
optionId: string,
256-
selectedOptionIds?: string[],
257256
customInput?: string,
257+
answers?: Record<string, string>,
258258
): void {
259259
const key = `${sessionId}:${toolCallId}`;
260260
const pending = this.pendingPermissions.get(key);
@@ -268,18 +268,20 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
268268
sessionId,
269269
toolCallId,
270270
optionId,
271-
selectedOptionIds,
272271
hasCustomInput: !!customInput,
272+
hasAnswers: !!answers,
273273
});
274274

275+
const meta: Record<string, unknown> = {};
276+
if (customInput) meta.customInput = customInput;
277+
if (answers) meta.answers = answers;
278+
275279
pending.resolve({
276280
outcome: {
277281
outcome: "selected",
278282
optionId,
279-
// Include multi-select and custom input in the response
280-
...(selectedOptionIds && { selectedOptionIds }),
281-
...(customInput && { customInput }),
282283
},
284+
...(Object.keys(meta).length > 0 && { _meta: meta }),
283285
});
284286

285287
this.pendingPermissions.delete(key);
@@ -924,13 +926,6 @@ For git operations while detached:
924926
// The claude.ts adapter only calls requestPermission when user input is needed.
925927
// (It handles auto-approve internally for acceptEdits/bypassPermissions modes)
926928
if (toolCallId) {
927-
log.info("Permission request requires user input", {
928-
sessionId: taskRunId,
929-
toolCallId,
930-
toolName,
931-
title: params.toolCall?.title,
932-
});
933-
934929
return new Promise((resolve, reject) => {
935930
const key = `${taskRunId}:${toolCallId}`;
936931
service.pendingPermissions.set(key, {
@@ -944,18 +939,7 @@ For git operations while detached:
944939
sessionId: taskRunId,
945940
toolCallId,
946941
});
947-
service.emit(AgentServiceEvent.PermissionRequest, {
948-
sessionId: taskRunId,
949-
toolCallId,
950-
title: params.toolCall?.title || "Permission Required",
951-
options: params.options.map((o) => ({
952-
kind: o.kind,
953-
name: o.name,
954-
optionId: o.optionId,
955-
description: (o as { description?: string }).description,
956-
})),
957-
rawInput: params.toolCall?.rawInput,
958-
});
942+
service.emit(AgentServiceEvent.PermissionRequest, params);
959943
});
960944
}
961945

apps/twig/src/main/trpc/routers/agent.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,8 @@ export const agentRouter = router({
112112
input.sessionId,
113113
input.toolCallId,
114114
input.optionId,
115-
input.selectedOptionIds,
116115
input.customInput,
116+
input.answers,
117117
),
118118
),
119119

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import type { Meta, StoryObj } from "@storybook/react-vite";
2+
import { ActionSelector } from "./ActionSelector";
3+
4+
const meta: Meta<typeof ActionSelector> = {
5+
title: "Components/ActionSelector",
6+
component: ActionSelector,
7+
parameters: {
8+
layout: "padded",
9+
},
10+
argTypes: {
11+
onSelect: { action: "selected" },
12+
onMultiSelect: { action: "multiSelected" },
13+
onCancel: { action: "cancelled" },
14+
onStepAnswer: { action: "stepAnswered" },
15+
},
16+
};
17+
18+
export default meta;
19+
type Story = StoryObj<typeof ActionSelector>;
20+
21+
export const SingleSelect: Story = {
22+
args: {
23+
title: "Single Select",
24+
question: "Choose one option:",
25+
options: [
26+
{ id: "a", label: "Option A", description: "First option" },
27+
{ id: "b", label: "Option B", description: "Second option" },
28+
{ id: "c", label: "Option C", description: "Third option" },
29+
],
30+
},
31+
};
32+
33+
export const WithCustomInput: Story = {
34+
args: {
35+
title: "With Custom Input",
36+
question: "Choose an option or provide your own:",
37+
options: [
38+
{ id: "a", label: "Option A" },
39+
{ id: "b", label: "Option B" },
40+
],
41+
allowCustomInput: true,
42+
customInputPlaceholder: "Type your answer...",
43+
},
44+
};
45+
46+
export const MultiSelect: Story = {
47+
args: {
48+
title: "Multi Select",
49+
question: "Select all that apply:",
50+
options: [
51+
{ id: "react", label: "React", description: "UI library" },
52+
{ id: "vue", label: "Vue", description: "Progressive framework" },
53+
{ id: "svelte", label: "Svelte", description: "Compiler-based" },
54+
{ id: "angular", label: "Angular", description: "Full framework" },
55+
],
56+
multiSelect: true,
57+
},
58+
};
59+
60+
export const MultiSelectWithOther: Story = {
61+
args: {
62+
title: "Multi Select with Other",
63+
question: "Which features do you want?",
64+
options: [
65+
{ id: "auth", label: "Authentication" },
66+
{ id: "db", label: "Database" },
67+
{ id: "api", label: "REST API" },
68+
],
69+
multiSelect: true,
70+
allowCustomInput: true,
71+
customInputPlaceholder: "Describe additional features...",
72+
},
73+
};
74+
75+
export const WithSteps: Story = {
76+
args: {
77+
title: "Frontend",
78+
question: "Which frontend framework do you prefer?",
79+
options: [
80+
{
81+
id: "react",
82+
label: "React",
83+
description: "Component-based UI library",
84+
},
85+
{ id: "vue", label: "Vue", description: "Progressive framework" },
86+
{ id: "svelte", label: "Svelte", description: "Compiler-based" },
87+
],
88+
multiSelect: true,
89+
allowCustomInput: true,
90+
customInputPlaceholder: "Type something",
91+
currentStep: 0,
92+
steps: [
93+
{ label: "Frontend" },
94+
{ label: "Backend" },
95+
{ label: "Databases" },
96+
{ label: "Submit" },
97+
],
98+
},
99+
};
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
export { ActionSelector } from "./action-selector/ActionSelector";
2+
export {
3+
CANCEL_OPTION_ID,
4+
filterOtherOptions,
5+
isCancelOption,
6+
isOtherOption,
7+
isSubmitOption,
8+
makeOptionId,
9+
OPTION_ID_PREFIX,
10+
OTHER_OPTION_ID,
11+
OTHER_OPTION_ID_ALT,
12+
parseOptionIndex,
13+
SUBMIT_OPTION_ID,
14+
} from "./action-selector/constants";
15+
export type {
16+
ActionSelectorProps,
17+
SelectorOption,
18+
StepAnswer,
19+
StepInfo,
20+
} from "./action-selector/types";

0 commit comments

Comments
 (0)