Skip to content

Commit 63f4c7a

Browse files
committed
chore: add notification settings and refactor gemini agent
- feat(settings): add configurable notification preferences with UI toggle - refactor(gemini): inline session logic into index, simplify argv - refactor(opencode): tidy argv construction and tests
1 parent 5c1b08b commit 63f4c7a

11 files changed

Lines changed: 134 additions & 81 deletions

File tree

src/main/sharedSettingsFile.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ describe("sharedSettingsFile", () => {
7373
notificationSound: true,
7474
notificationFilter: "unfocused",
7575
notificationStatuses: { done: true, needsAttention: true, error: true },
76+
notifyL2Cli: true,
7677
favoriteModels: [],
7778
recentModels: [],
7879
agentHookSupport: {},
@@ -126,6 +127,7 @@ describe("sharedSettingsFile", () => {
126127
notificationSound: true,
127128
notificationFilter: "unfocused",
128129
notificationStatuses: { done: true, needsAttention: true, error: true },
130+
notifyL2Cli: true,
129131
favoriteModels: [],
130132
recentModels: [],
131133
agentHookSupport: {},

src/renderer/notifications.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,8 @@ export function handleThreadStateNotification(
202202

203203
if (!settings.notificationStatuses[category]) return;
204204

205+
if (!settings.notifyL2Cli && oldThread.threadStatusSource === "terminal_parse") return;
206+
205207
const projectName = getProjectName(oldThread.projectId);
206208
const threadId = oldThread.id;
207209
const threadTitle = oldThread.title;

src/renderer/state/sharedSettingsStore.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ interface SharedSettingsState extends SharedSettings {
5959
needsAttention?: boolean;
6060
error?: boolean;
6161
}) => void;
62+
setNotifyL2Cli: (value: boolean) => void;
6263
toggleFavoriteModel: (agentKind: string, modelId: string) => void;
6364
pushRecentModel: (agentKind: string, modelId: string) => void;
6465
}
@@ -298,6 +299,11 @@ export const useSharedSettings = create<SharedSettingsState>()((set, get) => ({
298299
set({ notificationStatuses: next });
299300
persistSettings(selectSharedSettings(get()));
300301
},
302+
setNotifyL2Cli: (notifyL2Cli) => {
303+
if (get().notifyL2Cli === notifyL2Cli) return;
304+
set({ notifyL2Cli });
305+
persistSettings(selectSharedSettings(get()));
306+
},
301307
toggleFavoriteModel: (agentKind, modelId) => {
302308
const current = get().favoriteModels;
303309
const idx = current.findIndex((m) => m.agentKind === agentKind && m.modelId === modelId);
@@ -372,6 +378,7 @@ function selectSharedSettings(state: SharedSettingsState): SharedSettingsInput {
372378
notificationSound: state.notificationSound,
373379
notificationFilter: state.notificationFilter,
374380
notificationStatuses: state.notificationStatuses,
381+
notifyL2Cli: state.notifyL2Cli,
375382
favoriteModels: state.favoriteModels,
376383
recentModels: state.recentModels,
377384
};

src/renderer/views/SettingsOverlay/parts/NotificationSettings.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export function NotificationSettings() {
1818
const setNotificationFilter = useSharedSettings((s) => s.setNotificationFilter);
1919
const notificationStatuses = useSharedSettings((s) => s.notificationStatuses);
2020
const setNotificationStatuses = useSharedSettings((s) => s.setNotificationStatuses);
21+
const notifyL2Cli = useSharedSettings((s) => s.notifyL2Cli);
22+
const setNotifyL2Cli = useSharedSettings((s) => s.setNotifyL2Cli);
2123

2224
return (
2325
<div className="h-full min-h-0 overflow-y-auto px-6 pb-8 pt-4">
@@ -147,6 +149,28 @@ export function NotificationSettings() {
147149
</div>
148150
</div>
149151
</div>
152+
153+
<div className="flex items-center justify-between gap-4 pt-2">
154+
<div className="min-w-0">
155+
<p className="text-sm font-medium text-foreground">Notify for L2 CLI threads</p>
156+
<p className="text-xs text-muted">
157+
When off, suppress notifications from terminal threads whose status comes from the
158+
OSC fallback (no CLI hook plugin).
159+
</p>
160+
</div>
161+
<Switch
162+
isSelected={notifyL2Cli}
163+
onChange={(selected) => {
164+
startTransition(() => {
165+
setNotifyL2Cli(selected);
166+
});
167+
}}
168+
>
169+
<Switch.Control>
170+
<Switch.Thumb />
171+
</Switch.Control>
172+
</Switch>
173+
</div>
150174
</div>
151175
</div>
152176
</div>

src/shared/settings.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,13 @@ export const sharedSettingsSchema = z.object({
113113
needsAttention: z.boolean(),
114114
error: z.boolean(),
115115
}),
116+
/**
117+
* When false, suppress notifications for terminal-presentation threads whose
118+
* status is derived from the OSC/heuristic fallback (L2) rather than the CLI
119+
* hook plugin (L1). L2 status is less precise, so users can opt out of its
120+
* noisier transitions.
121+
*/
122+
notifyL2Cli: z.boolean(),
116123
/** User-starred (provider, model) entries surfaced at the top of the model picker. */
117124
favoriteModels: z.array(z.object({ agentKind: z.string().min(1), modelId: z.string().min(1) })),
118125
/**
@@ -185,6 +192,7 @@ export const defaultSharedSettings: SharedSettings = {
185192
notificationSound: true,
186193
notificationFilter: "unfocused",
187194
notificationStatuses: { done: true, needsAttention: true, error: true },
195+
notifyL2Cli: true,
188196
favoriteModels: [],
189197
recentModels: [],
190198
disableCliHookPlugin: false,

src/supervisor/agents/gemini/argv.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@ export function buildGeminiArgs(
44
config: ThreadConfig,
55
prompt: string,
66
resumeSessionId?: string,
7+
assignedSessionId?: string,
78
): string[] {
89
const args: string[] = [];
910

1011
if (resumeSessionId) {
1112
args.push("--resume", resumeSessionId);
13+
} else if (assignedSessionId) {
14+
args.push("--session-id", assignedSessionId);
1215
}
1316
if (config.model) {
1417
args.push("--model", config.model);

src/supervisor/agents/gemini/gemini.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import { describe, expect, it } from "vitest";
2+
import type { ProjectLocation, ThreadConfig } from "@/shared/contracts";
23
import { createGeminiAdapter } from ".";
4+
import { buildGeminiArgs } from "./argv";
35
import { geminiIntentFor } from "./plugin/intentMap";
46
import { detectGeminiInvalidSessionRef } from "./session";
57
import { detectGeminiTerminalStatus } from "./terminal";
68

9+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
10+
711
describe("detectGeminiTerminalStatus", () => {
812
it("detects idle from ◇ Ready title bar indicator", () => {
913
const text = "0;◇ Ready (my-project)";
@@ -181,6 +185,68 @@ describe("detectGeminiTerminalStatus", () => {
181185
});
182186
});
183187

188+
describe("buildGeminiArgs", () => {
189+
const config: ThreadConfig = { model: "gemini-2.5-pro" };
190+
191+
it("emits --session-id when an assignedSessionId is provided", () => {
192+
const args = buildGeminiArgs(config, "hello", undefined, "abc-uuid");
193+
const sessionIdx = args.indexOf("--session-id");
194+
expect(sessionIdx).toBeGreaterThanOrEqual(0);
195+
expect(args[sessionIdx + 1]).toBe("abc-uuid");
196+
expect(args).not.toContain("--resume");
197+
});
198+
199+
it("prefers --resume over --session-id when both are provided", () => {
200+
const args = buildGeminiArgs(config, "hello", "resume-uuid", "assigned-uuid");
201+
expect(args).toContain("--resume");
202+
expect(args).toContain("resume-uuid");
203+
expect(args).not.toContain("--session-id");
204+
expect(args).not.toContain("assigned-uuid");
205+
});
206+
207+
it("omits both flags when neither is provided", () => {
208+
const args = buildGeminiArgs(config, "hello");
209+
expect(args).not.toContain("--resume");
210+
expect(args).not.toContain("--session-id");
211+
});
212+
});
213+
214+
describe("createGeminiAdapter buildLaunchArgv", () => {
215+
const project: ProjectLocation = {
216+
kind: "windows",
217+
path: "C:\\demo",
218+
};
219+
const config: ThreadConfig = { model: "gemini-2.5-pro" };
220+
221+
it("assigns a stable session UUID at launch and returns it as sessionRef", () => {
222+
const adapter = createGeminiAdapter();
223+
const argv = adapter.buildLaunchArgv(project, config, "hi");
224+
225+
if (argv === undefined) throw new Error("expected argv");
226+
expect(argv.binary).toBe("gemini");
227+
228+
const sessionIdx = argv.args.indexOf("--session-id");
229+
expect(sessionIdx).toBeGreaterThanOrEqual(0);
230+
const uuid = argv.args[sessionIdx + 1]!;
231+
expect(uuid).toMatch(UUID_RE);
232+
233+
expect(argv.sessionRef?.providerSessionId).toBe(uuid);
234+
});
235+
236+
it("uses --resume (not --session-id) on resume", () => {
237+
const adapter = createGeminiAdapter();
238+
const argv = adapter.buildResumeArgv(project, config, "hi", {
239+
providerSessionId: "11111111-1111-4111-8111-111111111111",
240+
discoveredAt: "2026-05-15T00:00:00.000Z",
241+
});
242+
243+
if (argv === undefined) throw new Error("expected argv");
244+
expect(argv.args).toContain("--resume");
245+
expect(argv.args).toContain("11111111-1111-4111-8111-111111111111");
246+
expect(argv.args).not.toContain("--session-id");
247+
});
248+
});
249+
184250
describe("detectGeminiInvalidSessionRef", () => {
185251
it("detects Gemini invalid resume session errors", () => {
186252
expect(

src/supervisor/agents/gemini/index.ts

Lines changed: 15 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1+
import { randomUUID } from "node:crypto";
2+
13
import type { AgentCapability, PromptSegment } from "@/shared/contracts";
24
import { EXTRACTION_PROMPT } from "@/supervisor/contextExtractor";
35
import { createAcpStructuredSession } from "../acp";
46
import {
57
buildAgentCommand,
68
createKnownSessionRef,
7-
createRecursiveDirWatcher,
89
detectAgentInstall,
910
type AgentAdapter,
1011
type CreateStructuredSessionInput,
@@ -20,11 +21,7 @@ import {
2021
isGeminiPluginInstalled,
2122
readBundledGeminiPluginVersion,
2223
} from "./plugin/install";
23-
import {
24-
detectGeminiInvalidSessionRef,
25-
queryLatestSessionId,
26-
resolveGeminiWatchPath,
27-
} from "./session";
24+
import { detectGeminiInvalidSessionRef } from "./session";
2825
import { detectGeminiTerminalStatus } from "./terminal";
2926

3027
export { detectGeminiInvalidSessionRef } from "./session";
@@ -38,8 +35,6 @@ function geminiHookActiveTerminalFallback(hint: TerminalStatusHint): boolean {
3835
}
3936

4037
export function createGeminiAdapter(): AgentAdapter {
41-
/** Latest session ID seen before the TUI spawned — used to detect the new one. */
42-
let preSpawnLatestId: string | undefined;
4338
let capabilities: AgentCapability = defaultGeminiCapabilities;
4439

4540
return {
@@ -90,13 +85,18 @@ export function createGeminiAdapter(): AgentAdapter {
9085
return status;
9186
},
9287

93-
buildLaunchArgv(location, config, prompt) {
94-
// Snapshot the latest session ID before TUI spawn so we can detect the new one
95-
void queryLatestSessionId(location).then((id) => {
96-
preSpawnLatestId = id;
97-
});
98-
const args = buildGeminiArgs(config, prompt);
99-
return { binary: "gemini", args };
88+
buildLaunchArgv(_location, config, prompt) {
89+
// Pre-assign the session UUID via --session-id so we know it before
90+
// spawn. Avoids racing post-spawn discovery against one-shot `gemini -p`
91+
// calls (title gen, commit-msg, PR summary) that also create entries in
92+
// --list-sessions and would otherwise be picked as "the new session".
93+
const assignedId = randomUUID();
94+
const args = buildGeminiArgs(config, prompt, undefined, assignedId);
95+
return {
96+
binary: "gemini",
97+
args,
98+
sessionRef: createKnownSessionRef(assignedId),
99+
};
100100
},
101101

102102
buildResumeArgv(_location, config, prompt, sessionRef) {
@@ -141,21 +141,6 @@ export function createGeminiAdapter(): AgentAdapter {
141141

142142
defaultOneShotModel: "gemini-2.5-flash",
143143

144-
async discoverSessionRef(location) {
145-
try {
146-
const latestId = await queryLatestSessionId(location);
147-
if (!latestId || latestId === preSpawnLatestId) return undefined;
148-
return createKnownSessionRef(latestId);
149-
} catch {
150-
return undefined;
151-
}
152-
},
153-
watchSessionRef(location, onChanged) {
154-
const watchPath = resolveGeminiWatchPath(location);
155-
if (!watchPath) return undefined;
156-
return createRecursiveDirWatcher(watchPath, onChanged, `gemini:${location.kind}`);
157-
},
158-
159144
buildOneShotCommand(model, _effort, prompt) {
160145
if (!prompt) return undefined;
161146
return { command: "gemini", args: ["-p", prompt, "--model", model], stdin: "" };
Lines changed: 0 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,5 @@
1-
import type { ProjectLocation } from "@/shared/contracts";
2-
import { getProjectName } from "@/shared/wsl";
3-
import {
4-
readAgentCommandOutput,
5-
resolveAgentHomeSubpath,
6-
resolveExecutablePathAsync,
7-
} from "../base";
8-
import { resolveAgentBinaryPath } from "../binaryResolver";
9-
10-
const SESSION_UUID_RE = /\[([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\]/;
111
const INVALID_SESSION_RE = /Error resuming session:\s+Invalid session identifier/i;
122

13-
function parseAllSessionIds(output: string): string[] {
14-
const ids: string[] = [];
15-
for (const line of output.split(/\r?\n/)) {
16-
const match = SESSION_UUID_RE.exec(line);
17-
if (match?.[1]) ids.push(match[1]);
18-
}
19-
return ids;
20-
}
21-
223
export function detectGeminiInvalidSessionRef(output: string): boolean {
234
return INVALID_SESSION_RE.test(output);
245
}
25-
26-
export async function queryLatestSessionId(location: ProjectLocation): Promise<string | undefined> {
27-
const executablePath =
28-
location.kind === "wsl"
29-
? (resolveAgentBinaryPath(location, "gemini") ?? "gemini")
30-
: await resolveExecutablePathAsync("gemini");
31-
if (!executablePath) return undefined;
32-
const result = await readAgentCommandOutput(location, executablePath, ["--list-sessions"]);
33-
if (!result.ok) {
34-
if (location.kind === "wsl") {
35-
console.log("[gemini] --list-sessions (wsl) failed: %s", result.stderr);
36-
}
37-
return undefined;
38-
}
39-
if (!result.stdout) return undefined;
40-
const ids = parseAllSessionIds(result.stdout);
41-
return ids[ids.length - 1];
42-
}
43-
44-
export function resolveGeminiWatchPath(location: ProjectLocation): string | undefined {
45-
return resolveAgentHomeSubpath(location, `.gemini/tmp/${getProjectName(location)}`);
46-
}

src/supervisor/agents/opencode/argv.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,11 @@ export function buildOpenCodeArgs(
2020
if (config.model) {
2121
args.push("--model", config.model);
2222
}
23-
// OpenCode's `--variant` flag matches its SDK `variant` field — used to
24-
// pick provider-specific reasoning effort (high, max, minimal, …) for
25-
// models that publish a `variants` map in `opencode models --verbose`.
26-
if (config.effort && config.effort.length > 0) {
27-
args.push("--variant", config.effort);
28-
}
23+
// NOTE: `config.effort` (variant) is intentionally NOT forwarded here.
24+
// The opencode CLI (verified against 1.14.30) does not accept `--variant`;
25+
// passing it makes yargs abort with the help screen instead of starting the
26+
// TUI. The SDK `session.promptAsync({ variant })` path still applies it for
27+
// GUI threads; TUI launches fall back to the model default at session start.
2928
// Plan mode in the TUI is just the built-in `plan` agent (`opencode agent
3029
// list`). The default command accepts `--agent <name>` to pick it at
3130
// launch; the SDK runtime uses the same value via `prompt_async`.

0 commit comments

Comments
 (0)