Skip to content

Commit b986639

Browse files
authored
feat: Add personalization settings with custom instructions (#965)
You will assume the role and speech patterns of a cowboy from the old west. I want you to really lean into this when you work on tasks for me, even if they are modern in nature such as coding or math related.
1 parent f2237d8 commit b986639

7 files changed

Lines changed: 146 additions & 6 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export const startSessionInput = z.object({
4545
runMode: z.enum(["local", "cloud"]).optional(),
4646
adapter: z.enum(["claude", "codex"]).optional(),
4747
additionalDirectories: z.array(z.string()).optional(),
48+
customInstructions: z.string().max(2000).optional(),
4849
});
4950

5051
export type StartSessionInput = z.infer<typeof startSessionInput>;
@@ -160,6 +161,7 @@ export const reconnectSessionInput = z.object({
160161
/** Additional directories Claude can access beyond cwd (for worktree support) */
161162
additionalDirectories: z.array(z.string()).optional(),
162163
permissionMode: z.string().optional(),
164+
customInstructions: z.string().max(2000).optional(),
163165
});
164166

165167
export type ReconnectSessionInput = z.infer<typeof reconnectSessionInput>;

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

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,8 @@ interface SessionConfig {
177177
additionalDirectories?: string[];
178178
/** Permission mode to use for the session */
179179
permissionMode?: string;
180+
/** Custom instructions injected into the system prompt */
181+
customInstructions?: string;
180182
}
181183

182184
interface ManagedSession {
@@ -368,12 +370,19 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
368370
return servers;
369371
}
370372

371-
private buildPostHogSystemPrompt(credentials: Credentials): {
373+
private buildSystemPrompt(
374+
credentials: Credentials,
375+
customInstructions?: string,
376+
): {
372377
append: string;
373378
} {
374-
return {
375-
append: `PostHog context: use project ${credentials.projectId} on ${credentials.apiHost}. When using PostHog MCP tools, operate only on this project.`,
376-
};
379+
let prompt = `PostHog context: use project ${credentials.projectId} on ${credentials.apiHost}. When using PostHog MCP tools, operate only on this project.`;
380+
381+
if (customInstructions) {
382+
prompt += `\n\nUser custom instructions:\n${customInstructions}`;
383+
}
384+
385+
return { append: prompt };
377386
}
378387

379388
private getPostHogMcpUrl(apiHost: string): string {
@@ -426,6 +435,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
426435
adapter,
427436
additionalDirectories,
428437
permissionMode,
438+
customInstructions,
429439
} = config;
430440

431441
// Preview sessions don't need a real repo — use a temp directory
@@ -522,7 +532,10 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
522532
if (!config.sessionId) {
523533
throw new Error("Cannot resume session without sessionId");
524534
}
525-
const systemPrompt = this.buildPostHogSystemPrompt(credentials);
535+
const systemPrompt = this.buildSystemPrompt(
536+
credentials,
537+
customInstructions,
538+
);
526539
const resumeResponse = await connection.extMethod(
527540
"_posthog/session/resume",
528541
{
@@ -553,7 +566,10 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
553566
configOptions = resumeMeta?.configOptions;
554567
agentSessionId = config.sessionId;
555568
} else {
556-
const systemPrompt = this.buildPostHogSystemPrompt(credentials);
569+
const systemPrompt = this.buildSystemPrompt(
570+
credentials,
571+
customInstructions,
572+
);
557573
const newSessionResponse = await connection.newSession({
558574
cwd: repoPath,
559575
mcpServers,
@@ -1267,6 +1283,8 @@ For git operations while detached:
12671283
: undefined,
12681284
permissionMode:
12691285
"permissionMode" in params ? params.permissionMode : undefined,
1286+
customInstructions:
1287+
"customInstructions" in params ? params.customInstructions : undefined,
12701288
};
12711289
}
12721290

apps/twig/src/renderer/features/sessions/service/service.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
mergeConfigOptions,
2525
sessionStoreSetters,
2626
} from "@features/sessions/stores/sessionStore";
27+
import { useSettingsStore } from "@features/settings/stores/settingsStore";
2728
import { track } from "@renderer/lib/analytics";
2829
import { logger } from "@renderer/lib/logger";
2930
import {
@@ -324,6 +325,7 @@ export class SessionService {
324325
"mode",
325326
)?.currentValue;
326327

328+
const { customInstructions } = useSettingsStore.getState();
327329
const result = await trpcVanilla.agent.reconnect.mutate({
328330
taskId,
329331
taskRunId,
@@ -335,6 +337,7 @@ export class SessionService {
335337
sessionId,
336338
adapter: resolvedAdapter,
337339
permissionMode: persistedMode,
340+
customInstructions: customInstructions || undefined,
338341
});
339342

340343
if (result) {
@@ -496,6 +499,8 @@ export class SessionService {
496499
throw new Error("Failed to create task run. Please try again.");
497500
}
498501

502+
const { customInstructions: startCustomInstructions } =
503+
useSettingsStore.getState();
499504
const result = await trpcVanilla.agent.start.mutate({
500505
taskId,
501506
taskRunId: taskRun.id,
@@ -505,6 +510,7 @@ export class SessionService {
505510
projectId: auth.projectId,
506511
permissionMode: executionMode,
507512
adapter,
513+
customInstructions: startCustomInstructions || undefined,
508514
});
509515

510516
const session = this.createBaseSession(taskRun.id, taskId, taskTitle);
@@ -602,6 +608,8 @@ export class SessionService {
602608
sessionStoreSetters.setSession(session);
603609

604610
try {
611+
const { customInstructions: previewCustomInstructions } =
612+
useSettingsStore.getState();
605613
const result = await trpcVanilla.agent.start.mutate({
606614
taskId: PREVIEW_TASK_ID,
607615
taskRunId,
@@ -610,6 +618,7 @@ export class SessionService {
610618
apiHost: auth.apiHost,
611619
projectId: auth.projectId,
612620
adapter: params.adapter,
621+
customInstructions: previewCustomInstructions || undefined,
613622
});
614623

615624
if (abort.signal.aborted) {

apps/twig/src/renderer/features/settings/components/SettingsDialog.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
Folder,
1111
GearSix,
1212
Keyboard,
13+
Palette,
1314
User,
1415
Wrench,
1516
} from "@phosphor-icons/react";
@@ -20,6 +21,7 @@ import { AccountSettings } from "./sections/AccountSettings";
2021
import { AdvancedSettings } from "./sections/AdvancedSettings";
2122
import { ClaudeCodeSettings } from "./sections/ClaudeCodeSettings";
2223
import { GeneralSettings } from "./sections/GeneralSettings";
24+
import { PersonalizationSettings } from "./sections/PersonalizationSettings";
2325
import { ShortcutsSettings } from "./sections/ShortcutsSettings";
2426
import { UpdatesSettings } from "./sections/UpdatesSettings";
2527
import { WorkspacesSettings } from "./sections/WorkspacesSettings";
@@ -35,6 +37,11 @@ const SIDEBAR_ITEMS: SidebarItem[] = [
3537
{ id: "general", label: "General", icon: <GearSix size={16} /> },
3638
{ id: "account", label: "Account", icon: <User size={16} /> },
3739
{ id: "workspaces", label: "Workspaces", icon: <Folder size={16} /> },
40+
{
41+
id: "personalization",
42+
label: "Personalization",
43+
icon: <Palette size={16} />,
44+
},
3845
{ id: "claude-code", label: "Claude Code", icon: <Code size={16} /> },
3946
{ id: "shortcuts", label: "Shortcuts", icon: <Keyboard size={16} /> },
4047
{ id: "updates", label: "Updates", icon: <ArrowsClockwise size={16} /> },
@@ -45,6 +52,7 @@ const CATEGORY_TITLES: Record<SettingsCategory, string> = {
4552
general: "General",
4653
account: "Account",
4754
workspaces: "Workspaces",
55+
personalization: "Personalization",
4856
"claude-code": "Claude Code",
4957
shortcuts: "Shortcuts",
5058
updates: "Updates",
@@ -55,6 +63,7 @@ const CATEGORY_COMPONENTS: Record<SettingsCategory, React.ComponentType> = {
5563
general: GeneralSettings,
5664
account: AccountSettings,
5765
workspaces: WorkspacesSettings,
66+
personalization: PersonalizationSettings,
5867
"claude-code": ClaudeCodeSettings,
5968
shortcuts: ShortcutsSettings,
6069
updates: UpdatesSettings,
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { useSettingsStore } from "@features/settings/stores/settingsStore";
2+
import { Flex, Text, TextArea } from "@radix-ui/themes";
3+
import { track } from "@renderer/lib/analytics";
4+
import { ANALYTICS_EVENTS } from "@shared/types/analytics";
5+
import { useCallback, useEffect, useRef, useState } from "react";
6+
7+
const MAX_INSTRUCTIONS_LENGTH = 2000;
8+
const DEBOUNCE_MS = 500;
9+
10+
export function PersonalizationSettings() {
11+
const customInstructions = useSettingsStore((s) => s.customInstructions);
12+
const setCustomInstructions = useSettingsStore(
13+
(s) => s.setCustomInstructions,
14+
);
15+
16+
const [localInstructions, setLocalInstructions] =
17+
useState(customInstructions);
18+
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
19+
20+
// Sync local state when store changes externally
21+
useEffect(() => {
22+
setLocalInstructions(customInstructions);
23+
}, [customInstructions]);
24+
25+
const saveInstructions = useCallback(
26+
(value: string) => {
27+
const current = useSettingsStore.getState().customInstructions;
28+
if (value === current) return;
29+
setCustomInstructions(value);
30+
track(ANALYTICS_EVENTS.SETTING_CHANGED, {
31+
setting_name: "custom_instructions",
32+
new_value: value.length > 0,
33+
});
34+
},
35+
[setCustomInstructions],
36+
);
37+
38+
const handleInstructionsChange = useCallback(
39+
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
40+
const value = e.target.value;
41+
setLocalInstructions(value);
42+
43+
if (debounceRef.current) {
44+
clearTimeout(debounceRef.current);
45+
}
46+
debounceRef.current = setTimeout(() => {
47+
saveInstructions(value);
48+
}, DEBOUNCE_MS);
49+
},
50+
[saveInstructions],
51+
);
52+
53+
const handleInstructionsBlur = useCallback(() => {
54+
if (debounceRef.current) {
55+
clearTimeout(debounceRef.current);
56+
debounceRef.current = null;
57+
}
58+
saveInstructions(localInstructions);
59+
}, [localInstructions, saveInstructions]);
60+
61+
// Cleanup debounce on unmount
62+
useEffect(() => {
63+
return () => {
64+
if (debounceRef.current) {
65+
clearTimeout(debounceRef.current);
66+
}
67+
};
68+
}, []);
69+
70+
return (
71+
<Flex direction="column" gap="1" py="4">
72+
<Flex direction="column" gap="1" className="mb-2">
73+
<Text size="2" weight="medium">
74+
Custom instructions
75+
</Text>
76+
<Text size="1" color="gray">
77+
Instructions included in every agent session
78+
</Text>
79+
</Flex>
80+
<TextArea
81+
value={localInstructions}
82+
onChange={handleInstructionsChange}
83+
onBlur={handleInstructionsBlur}
84+
maxLength={MAX_INSTRUCTIONS_LENGTH}
85+
placeholder="e.g. Always write tests for new code. Prefer functional patterns."
86+
rows={6}
87+
size="1"
88+
style={{ width: "100%", fontFamily: "var(--font-mono)" }}
89+
/>
90+
<Text size="1" color="gray" align="right">
91+
{localInstructions.length}/{MAX_INSTRUCTIONS_LENGTH}
92+
</Text>
93+
</Flex>
94+
);
95+
}

apps/twig/src/renderer/features/settings/stores/settingsDialogStore.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export type SettingsCategory =
44
| "general"
55
| "account"
66
| "workspaces"
7+
| "personalization"
78
| "claude-code"
89
| "shortcuts"
910
| "updates"

apps/twig/src/renderer/features/settings/stores/settingsStore.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ interface SettingsStore {
2626
sendMessagesWith: SendMessagesWith;
2727
allowBypassPermissions: boolean;
2828
preventSleepWhileRunning: boolean;
29+
customInstructions: string;
2930

3031
setCompletionSound: (sound: CompletionSound) => void;
3132
setCompletionVolume: (volume: number) => void;
@@ -43,6 +44,7 @@ interface SettingsStore {
4344
setSendMessagesWith: (mode: SendMessagesWith) => void;
4445
setAllowBypassPermissions: (enabled: boolean) => void;
4546
setPreventSleepWhileRunning: (enabled: boolean) => void;
47+
setCustomInstructions: (instructions: string) => void;
4648
}
4749

4850
export const useSettingsStore = create<SettingsStore>()(
@@ -64,6 +66,7 @@ export const useSettingsStore = create<SettingsStore>()(
6466
sendMessagesWith: "enter",
6567
allowBypassPermissions: false,
6668
preventSleepWhileRunning: false,
69+
customInstructions: "",
6770

6871
setCompletionSound: (sound) => set({ completionSound: sound }),
6972
setCompletionVolume: (volume) => set({ completionVolume: volume }),
@@ -88,6 +91,8 @@ export const useSettingsStore = create<SettingsStore>()(
8891
set({ allowBypassPermissions: enabled }),
8992
setPreventSleepWhileRunning: (enabled) =>
9093
set({ preventSleepWhileRunning: enabled }),
94+
setCustomInstructions: (instructions) =>
95+
set({ customInstructions: instructions }),
9196
}),
9297
{
9398
name: "settings-storage",
@@ -109,6 +114,7 @@ export const useSettingsStore = create<SettingsStore>()(
109114
sendMessagesWith: state.sendMessagesWith,
110115
allowBypassPermissions: state.allowBypassPermissions,
111116
preventSleepWhileRunning: state.preventSleepWhileRunning,
117+
customInstructions: state.customInstructions,
112118
}),
113119
merge: (persisted, current) => ({
114120
...current,

0 commit comments

Comments
 (0)