@mseep/obsidian-agent-client 0.10.6

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 (146) hide show
  1. package/.claude/hooks/gh-setup.sh +49 -0
  2. package/.claude/settings.json +15 -0
  3. package/.claude/skills/release-notes/SKILL.md +331 -0
  4. package/.editorconfig +10 -0
  5. package/.github/FUNDING.yml +2 -0
  6. package/.github/ISSUE_TEMPLATE/bug_report.yml +90 -0
  7. package/.github/ISSUE_TEMPLATE/config.yml +11 -0
  8. package/.github/ISSUE_TEMPLATE/feature_request.yml +59 -0
  9. package/.github/copilot-instructions.md +45 -0
  10. package/.github/pull_request_template.md +32 -0
  11. package/.github/workflows/ci.yaml +25 -0
  12. package/.github/workflows/docs.yml +58 -0
  13. package/.github/workflows/relay_to_openclaw.yml +59 -0
  14. package/.github/workflows/release.yaml +45 -0
  15. package/.prettierignore +10 -0
  16. package/.prettierrc +13 -0
  17. package/.vscode/extensions.json +7 -0
  18. package/.vscode/settings.json +37 -0
  19. package/.zed/settings.json +42 -0
  20. package/AGENTS.md +330 -0
  21. package/ARCHITECTURE.md +390 -0
  22. package/CONTRIBUTING.md +216 -0
  23. package/LICENSE +202 -0
  24. package/NOTICE +2 -0
  25. package/README.ja.md +121 -0
  26. package/README.md +125 -0
  27. package/docs/.vitepress/config.mts +124 -0
  28. package/docs/.vitepress/theme/custom.css +111 -0
  29. package/docs/.vitepress/theme/index.ts +4 -0
  30. package/docs/agent-setup/claude-code.md +84 -0
  31. package/docs/agent-setup/codex.md +76 -0
  32. package/docs/agent-setup/custom-agents.md +67 -0
  33. package/docs/agent-setup/gemini-cli.md +99 -0
  34. package/docs/agent-setup/index.md +34 -0
  35. package/docs/announcements/gemini-cli-deprecation.md +73 -0
  36. package/docs/getting-started/index.md +78 -0
  37. package/docs/getting-started/quick-start.md +38 -0
  38. package/docs/help/faq.md +181 -0
  39. package/docs/help/troubleshooting.md +221 -0
  40. package/docs/index.md +63 -0
  41. package/docs/public/apple-touch-icon.png +0 -0
  42. package/docs/public/demo.mp4 +0 -0
  43. package/docs/public/favicon-16x16.png +0 -0
  44. package/docs/public/favicon-32x32.png +0 -0
  45. package/docs/public/favicon.ico +0 -0
  46. package/docs/public/images/editing.webp +0 -0
  47. package/docs/public/images/export.webp +0 -0
  48. package/docs/public/images/floating-chat-button.webp +0 -0
  49. package/docs/public/images/floating-chat-instance-menu.webp +0 -0
  50. package/docs/public/images/floating-chat-view.webp +0 -0
  51. package/docs/public/images/mode-selection.webp +0 -0
  52. package/docs/public/images/model-selection.webp +0 -0
  53. package/docs/public/images/multi-session.webp +0 -0
  54. package/docs/public/images/remove-image.webp +0 -0
  55. package/docs/public/images/ribbon-icon.webp +0 -0
  56. package/docs/public/images/selection-context.gif +0 -0
  57. package/docs/public/images/sending-images.webp +0 -0
  58. package/docs/public/images/sending-messages.webp +0 -0
  59. package/docs/public/images/session-history-button.webp +0 -0
  60. package/docs/public/images/slash-commands-1.webp +0 -0
  61. package/docs/public/images/slash-commands-2.webp +0 -0
  62. package/docs/public/images/switch-agent.webp +0 -0
  63. package/docs/public/images/switch-default-agent.webp +0 -0
  64. package/docs/public/images/temporary-disable.gif +0 -0
  65. package/docs/reference/acp-support.md +110 -0
  66. package/docs/usage/chat-export.md +80 -0
  67. package/docs/usage/commands.md +51 -0
  68. package/docs/usage/context-files.md +57 -0
  69. package/docs/usage/editing.md +69 -0
  70. package/docs/usage/floating-chat.md +84 -0
  71. package/docs/usage/index.md +97 -0
  72. package/docs/usage/mcp-tools.md +33 -0
  73. package/docs/usage/mentions.md +70 -0
  74. package/docs/usage/mode-selection.md +28 -0
  75. package/docs/usage/model-selection.md +32 -0
  76. package/docs/usage/multi-session.md +68 -0
  77. package/docs/usage/sending-images.md +64 -0
  78. package/docs/usage/session-history.md +91 -0
  79. package/docs/usage/slash-commands.md +44 -0
  80. package/esbuild.config.mjs +49 -0
  81. package/eslint.config.mjs +25 -0
  82. package/main.js +228 -0
  83. package/manifest.json +11 -0
  84. package/package.json +52 -0
  85. package/src/acp/acp-client.ts +921 -0
  86. package/src/acp/acp-handler.ts +252 -0
  87. package/src/acp/permission-handler.ts +282 -0
  88. package/src/acp/terminal-handler.ts +264 -0
  89. package/src/acp/type-converter.ts +272 -0
  90. package/src/hooks/useAgent.ts +250 -0
  91. package/src/hooks/useAgentMessages.ts +470 -0
  92. package/src/hooks/useAgentSession.ts +544 -0
  93. package/src/hooks/useChatActions.ts +400 -0
  94. package/src/hooks/useHistoryModal.ts +219 -0
  95. package/src/hooks/useSessionHistory.ts +863 -0
  96. package/src/hooks/useSettings.ts +19 -0
  97. package/src/hooks/useSuggestions.ts +342 -0
  98. package/src/main.ts +9 -0
  99. package/src/plugin.ts +1126 -0
  100. package/src/services/chat-exporter.ts +552 -0
  101. package/src/services/message-sender.ts +755 -0
  102. package/src/services/message-state.ts +375 -0
  103. package/src/services/session-helpers.ts +211 -0
  104. package/src/services/session-state.ts +130 -0
  105. package/src/services/session-storage.ts +267 -0
  106. package/src/services/settings-normalizer.ts +255 -0
  107. package/src/services/settings-service.ts +285 -0
  108. package/src/services/update-checker.ts +128 -0
  109. package/src/services/vault-service.ts +558 -0
  110. package/src/services/view-registry.ts +345 -0
  111. package/src/types/agent.ts +92 -0
  112. package/src/types/chat.ts +351 -0
  113. package/src/types/errors.ts +136 -0
  114. package/src/types/obsidian-internals.d.ts +14 -0
  115. package/src/types/session.ts +731 -0
  116. package/src/ui/ChangeDirectoryModal.ts +137 -0
  117. package/src/ui/ChatContext.ts +25 -0
  118. package/src/ui/ChatHeader.tsx +295 -0
  119. package/src/ui/ChatPanel.tsx +1162 -0
  120. package/src/ui/ChatView.tsx +348 -0
  121. package/src/ui/ErrorBanner.tsx +104 -0
  122. package/src/ui/FloatingButton.tsx +351 -0
  123. package/src/ui/FloatingChatView.tsx +531 -0
  124. package/src/ui/InputArea.tsx +1107 -0
  125. package/src/ui/InputToolbar.tsx +371 -0
  126. package/src/ui/MessageBubble.tsx +442 -0
  127. package/src/ui/MessageList.tsx +265 -0
  128. package/src/ui/PermissionBanner.tsx +61 -0
  129. package/src/ui/SessionHistoryModal.tsx +821 -0
  130. package/src/ui/SettingsTab.ts +1337 -0
  131. package/src/ui/SuggestionPopup.tsx +138 -0
  132. package/src/ui/TerminalBlock.tsx +107 -0
  133. package/src/ui/ToolCallBlock.tsx +456 -0
  134. package/src/ui/shared/AttachmentStrip.tsx +57 -0
  135. package/src/ui/shared/IconButton.tsx +55 -0
  136. package/src/ui/shared/MarkdownRenderer.tsx +103 -0
  137. package/src/ui/view-host.ts +56 -0
  138. package/src/utils/error-utils.ts +274 -0
  139. package/src/utils/logger.ts +44 -0
  140. package/src/utils/mention-parser.ts +129 -0
  141. package/src/utils/paths.ts +246 -0
  142. package/src/utils/platform.ts +425 -0
  143. package/styles.css +2322 -0
  144. package/tsconfig.json +18 -0
  145. package/version-bump.mjs +18 -0
  146. package/versions.json +3 -0
@@ -0,0 +1,375 @@
1
+ /**
2
+ * Pure functions for message state updates.
3
+ *
4
+ * These functions are extracted from useMessages to keep the hook thin
5
+ * and to allow independent testing. They handle message array transformations
6
+ * for streaming updates, tool call management, and permission state.
7
+ */
8
+
9
+ import type {
10
+ ChatMessage,
11
+ MessageContent,
12
+ ActivePermission,
13
+ PermissionOption,
14
+ } from "../types/chat";
15
+ import type { SessionUpdate } from "../types/session";
16
+
17
+ // ============================================================================
18
+ // Types
19
+ // ============================================================================
20
+
21
+ /** Tool call content type extracted for type safety */
22
+ export type ToolCallMessageContent = Extract<
23
+ MessageContent,
24
+ { type: "tool_call" }
25
+ >;
26
+
27
+ // ============================================================================
28
+ // Tool Call Merge
29
+ // ============================================================================
30
+
31
+ /**
32
+ * Merge new tool call content into existing tool call.
33
+ * Preserves existing values when new values are undefined.
34
+ */
35
+ export function mergeToolCallContent(
36
+ existing: ToolCallMessageContent,
37
+ update: ToolCallMessageContent,
38
+ ): ToolCallMessageContent {
39
+ // Merge content arrays
40
+ let mergedContent = existing.content || [];
41
+ if (update.content !== undefined) {
42
+ const newContent = update.content || [];
43
+
44
+ // If new content contains diff, replace all old diffs
45
+ const hasDiff = newContent.some((item) => item.type === "diff");
46
+ if (hasDiff) {
47
+ mergedContent = mergedContent.filter(
48
+ (item) => item.type !== "diff",
49
+ );
50
+ }
51
+
52
+ mergedContent = [...mergedContent, ...newContent];
53
+ }
54
+
55
+ return {
56
+ ...existing,
57
+ toolCallId: update.toolCallId,
58
+ title: update.title !== undefined ? update.title : existing.title,
59
+ kind: update.kind !== undefined ? update.kind : existing.kind,
60
+ status: update.status !== undefined ? update.status : existing.status,
61
+ content: mergedContent,
62
+ locations:
63
+ update.locations !== undefined
64
+ ? update.locations
65
+ : existing.locations,
66
+ rawInput:
67
+ update.rawInput !== undefined &&
68
+ Object.keys(update.rawInput).length > 0
69
+ ? update.rawInput
70
+ : existing.rawInput,
71
+ permissionRequest:
72
+ update.permissionRequest !== undefined
73
+ ? update.permissionRequest
74
+ : existing.permissionRequest,
75
+ };
76
+ }
77
+
78
+ // ============================================================================
79
+ // Message Array Update Functions (for batching)
80
+ // ============================================================================
81
+
82
+ /**
83
+ * Apply a "last assistant message" update to the messages array.
84
+ * Creates a new assistant message if needed.
85
+ */
86
+ export function applyUpdateLastMessage(
87
+ prev: ChatMessage[],
88
+ content: MessageContent,
89
+ ): ChatMessage[] {
90
+ if (prev.length === 0 || prev[prev.length - 1].role !== "assistant") {
91
+ const newMessage: ChatMessage = {
92
+ id: crypto.randomUUID(),
93
+ role: "assistant",
94
+ content: [content],
95
+ timestamp: new Date(),
96
+ };
97
+ return [...prev, newMessage];
98
+ }
99
+
100
+ const lastMessage = prev[prev.length - 1];
101
+ const updatedMessage = { ...lastMessage };
102
+
103
+ if (content.type === "text" || content.type === "agent_thought") {
104
+ const existingContentIndex = updatedMessage.content.findIndex(
105
+ (c) => c.type === content.type,
106
+ );
107
+ if (existingContentIndex >= 0) {
108
+ const existingContent =
109
+ updatedMessage.content[existingContentIndex];
110
+ if (
111
+ existingContent.type === "text" ||
112
+ existingContent.type === "agent_thought"
113
+ ) {
114
+ updatedMessage.content[existingContentIndex] = {
115
+ type: content.type,
116
+ text: existingContent.text + content.text,
117
+ };
118
+ }
119
+ } else {
120
+ updatedMessage.content.push(content);
121
+ }
122
+ } else {
123
+ const existingIndex = updatedMessage.content.findIndex(
124
+ (c) => c.type === content.type,
125
+ );
126
+ if (existingIndex >= 0) {
127
+ updatedMessage.content[existingIndex] = content;
128
+ } else {
129
+ updatedMessage.content.push(content);
130
+ }
131
+ }
132
+
133
+ return [...prev.slice(0, -1), updatedMessage];
134
+ }
135
+
136
+ /**
137
+ * Apply a "last user message" update to the messages array.
138
+ * Creates a new user message if needed. Used for session/load history replay.
139
+ */
140
+ export function applyUpdateUserMessage(
141
+ prev: ChatMessage[],
142
+ content: MessageContent,
143
+ ): ChatMessage[] {
144
+ if (prev.length === 0 || prev[prev.length - 1].role !== "user") {
145
+ const newMessage: ChatMessage = {
146
+ id: crypto.randomUUID(),
147
+ role: "user",
148
+ content: [content],
149
+ timestamp: new Date(),
150
+ };
151
+ return [...prev, newMessage];
152
+ }
153
+
154
+ const lastMessage = prev[prev.length - 1];
155
+ const updatedMessage = { ...lastMessage };
156
+
157
+ if (content.type === "text") {
158
+ const existingContentIndex = updatedMessage.content.findIndex(
159
+ (c) => c.type === "text",
160
+ );
161
+ if (existingContentIndex >= 0) {
162
+ const existingContent =
163
+ updatedMessage.content[existingContentIndex];
164
+ if (existingContent.type === "text") {
165
+ updatedMessage.content[existingContentIndex] = {
166
+ type: "text",
167
+ text: existingContent.text + content.text,
168
+ };
169
+ }
170
+ } else {
171
+ updatedMessage.content.push(content);
172
+ }
173
+ } else {
174
+ const existingIndex = updatedMessage.content.findIndex(
175
+ (c) => c.type === content.type,
176
+ );
177
+ if (existingIndex >= 0) {
178
+ updatedMessage.content[existingIndex] = content;
179
+ } else {
180
+ updatedMessage.content.push(content);
181
+ }
182
+ }
183
+
184
+ return [...prev.slice(0, -1), updatedMessage];
185
+ }
186
+
187
+ /**
188
+ * Apply a tool call upsert to the messages array.
189
+ * If a tool call with the given ID exists, merges. Otherwise creates new message.
190
+ */
191
+ export function applyUpsertToolCall(
192
+ prev: ChatMessage[],
193
+ content: ToolCallMessageContent,
194
+ toolCallIndex: Map<string, number>,
195
+ ): ChatMessage[] {
196
+ // O(1) lookup via index
197
+ const messageIdx = toolCallIndex.get(content.toolCallId);
198
+ if (messageIdx !== undefined && messageIdx < prev.length) {
199
+ const message = prev[messageIdx];
200
+ const hasTarget = message.content.some(
201
+ (c) =>
202
+ c.type === "tool_call" && c.toolCallId === content.toolCallId,
203
+ );
204
+ if (hasTarget) {
205
+ const updatedMessage = {
206
+ ...message,
207
+ content: message.content.map((c) => {
208
+ if (
209
+ c.type === "tool_call" &&
210
+ c.toolCallId === content.toolCallId
211
+ ) {
212
+ return mergeToolCallContent(c, content);
213
+ }
214
+ return c;
215
+ }),
216
+ };
217
+ const result = [...prev];
218
+ result[messageIdx] = updatedMessage;
219
+ return result;
220
+ }
221
+ }
222
+
223
+ // Fallback: linear scan (index miss or stale index)
224
+ let found = false;
225
+ const updated = prev.map((message, idx) => {
226
+ const hasTarget = message.content.some(
227
+ (c) =>
228
+ c.type === "tool_call" && c.toolCallId === content.toolCallId,
229
+ );
230
+ if (!hasTarget) return message;
231
+ found = true;
232
+ toolCallIndex.set(content.toolCallId, idx); // Fix stale index
233
+ return {
234
+ ...message,
235
+ content: message.content.map((c) => {
236
+ if (
237
+ c.type === "tool_call" &&
238
+ c.toolCallId === content.toolCallId
239
+ ) {
240
+ return mergeToolCallContent(c, content);
241
+ }
242
+ return c;
243
+ }),
244
+ };
245
+ });
246
+
247
+ if (found) return updated;
248
+
249
+ // Not found: create new message and register in index
250
+ toolCallIndex.set(content.toolCallId, prev.length);
251
+ return [
252
+ ...prev,
253
+ {
254
+ id: crypto.randomUUID(),
255
+ role: "assistant" as const,
256
+ content: [content],
257
+ timestamp: new Date(),
258
+ },
259
+ ];
260
+ }
261
+
262
+ /**
263
+ * Rebuild the tool call index from a messages array.
264
+ */
265
+ export function rebuildToolCallIndex(
266
+ messages: ChatMessage[],
267
+ toolCallIndex: Map<string, number>,
268
+ ): void {
269
+ toolCallIndex.clear();
270
+ messages.forEach((msg, msgIdx) => {
271
+ for (const c of msg.content) {
272
+ if (c.type === "tool_call") {
273
+ toolCallIndex.set(c.toolCallId, msgIdx);
274
+ }
275
+ }
276
+ });
277
+ }
278
+
279
+ /**
280
+ * Apply a single session update to the messages array.
281
+ * Returns the same array reference if no change (session-level updates).
282
+ */
283
+ export function applySingleUpdate(
284
+ prev: ChatMessage[],
285
+ update: SessionUpdate,
286
+ toolCallIndex: Map<string, number>,
287
+ ): ChatMessage[] {
288
+ switch (update.type) {
289
+ case "agent_message_chunk":
290
+ return applyUpdateLastMessage(prev, {
291
+ type: "text",
292
+ text: update.text,
293
+ });
294
+ case "agent_thought_chunk":
295
+ return applyUpdateLastMessage(prev, {
296
+ type: "agent_thought",
297
+ text: update.text,
298
+ });
299
+ case "user_message_chunk":
300
+ return applyUpdateUserMessage(prev, {
301
+ type: "text",
302
+ text: update.text,
303
+ });
304
+ case "tool_call":
305
+ case "tool_call_update":
306
+ return applyUpsertToolCall(
307
+ prev,
308
+ {
309
+ type: "tool_call",
310
+ toolCallId: update.toolCallId,
311
+ title: update.title,
312
+ status: update.status || "pending",
313
+ kind: update.kind,
314
+ content: update.content,
315
+ locations: update.locations,
316
+ rawInput: update.rawInput,
317
+ permissionRequest: update.permissionRequest,
318
+ },
319
+ toolCallIndex,
320
+ );
321
+ case "plan":
322
+ return applyUpdateLastMessage(prev, {
323
+ type: "plan",
324
+ entries: update.entries,
325
+ });
326
+ default:
327
+ return prev;
328
+ }
329
+ }
330
+
331
+ // ============================================================================
332
+ // Permission Helper Functions
333
+ // ============================================================================
334
+
335
+ /**
336
+ * Find the active permission request from messages.
337
+ */
338
+ export function findActivePermission(
339
+ messages: ChatMessage[],
340
+ ): ActivePermission | null {
341
+ for (const message of messages) {
342
+ for (const content of message.content) {
343
+ if (content.type === "tool_call") {
344
+ const permission = content.permissionRequest;
345
+ if (permission?.isActive) {
346
+ return {
347
+ requestId: permission.requestId,
348
+ toolCallId: content.toolCallId,
349
+ options: permission.options,
350
+ };
351
+ }
352
+ }
353
+ }
354
+ }
355
+ return null;
356
+ }
357
+
358
+ /**
359
+ * Select an option from the available options based on preferred kinds.
360
+ */
361
+ export function selectOption(
362
+ options: PermissionOption[],
363
+ preferredKinds: PermissionOption["kind"][],
364
+ fallback?: (option: PermissionOption) => boolean,
365
+ ): PermissionOption | undefined {
366
+ for (const kind of preferredKinds) {
367
+ const match = options.find((opt) => opt.kind === kind);
368
+ if (match) return match;
369
+ }
370
+ if (fallback) {
371
+ const fallbackOption = options.find(fallback);
372
+ if (fallbackOption) return fallbackOption;
373
+ }
374
+ return options[0];
375
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Pure helper functions for agent session management.
3
+ * Extracted from useSession hook for reusability and testability.
4
+ */
5
+
6
+ import type { AgentClientPluginSettings } from "../plugin";
7
+ import type {
8
+ BaseAgentSettings,
9
+ ClaudeAgentSettings,
10
+ GeminiAgentSettings,
11
+ CodexAgentSettings,
12
+ } from "../types/agent";
13
+ import type { ChatSession } from "../types/session";
14
+ import { toAgentConfig } from "./settings-normalizer";
15
+ import type { AgentUpdateNotification } from "./update-checker";
16
+
17
+ // ============================================================================
18
+ // Types
19
+ // ============================================================================
20
+
21
+ /**
22
+ * Agent information for display.
23
+ * (Inlined from SwitchAgentUseCase)
24
+ */
25
+ export interface AgentDisplayInfo {
26
+ /** Unique agent ID */
27
+ id: string;
28
+ /** Display name for UI */
29
+ displayName: string;
30
+ }
31
+
32
+ // ============================================================================
33
+ // Helper Functions (Inlined from SwitchAgentUseCase)
34
+ // ============================================================================
35
+
36
+ /**
37
+ * Get the default agent ID from settings (for new views).
38
+ */
39
+ export function getDefaultAgentId(settings: AgentClientPluginSettings): string {
40
+ return settings.defaultAgentId || settings.claude.id;
41
+ }
42
+
43
+ /**
44
+ * Get list of all available agents from settings.
45
+ */
46
+ export function getAvailableAgentsFromSettings(
47
+ settings: AgentClientPluginSettings,
48
+ ): AgentDisplayInfo[] {
49
+ return [
50
+ {
51
+ id: settings.claude.id,
52
+ displayName: settings.claude.displayName || settings.claude.id,
53
+ },
54
+ {
55
+ id: settings.codex.id,
56
+ displayName: settings.codex.displayName || settings.codex.id,
57
+ },
58
+ {
59
+ id: settings.gemini.id,
60
+ displayName: settings.gemini.displayName || settings.gemini.id,
61
+ },
62
+ ...settings.customAgents.map((agent) => ({
63
+ id: agent.id,
64
+ displayName: agent.displayName || agent.id,
65
+ })),
66
+ ];
67
+ }
68
+
69
+ /**
70
+ * Get the currently active agent information from settings.
71
+ */
72
+ export function getCurrentAgent(
73
+ settings: AgentClientPluginSettings,
74
+ agentId?: string,
75
+ ): AgentDisplayInfo {
76
+ const activeId = agentId || getDefaultAgentId(settings);
77
+ const agents = getAvailableAgentsFromSettings(settings);
78
+ return (
79
+ agents.find((agent) => agent.id === activeId) || {
80
+ id: activeId,
81
+ displayName: activeId,
82
+ }
83
+ );
84
+ }
85
+
86
+ // ============================================================================
87
+ // Helper Functions (Inlined from ManageSessionUseCase)
88
+ // ============================================================================
89
+
90
+ /**
91
+ * Find agent settings by ID from plugin settings.
92
+ */
93
+ export function findAgentSettings(
94
+ settings: AgentClientPluginSettings,
95
+ agentId: string,
96
+ ): BaseAgentSettings | null {
97
+ if (agentId === settings.claude.id) {
98
+ return settings.claude;
99
+ }
100
+ if (agentId === settings.codex.id) {
101
+ return settings.codex;
102
+ }
103
+ if (agentId === settings.gemini.id) {
104
+ return settings.gemini;
105
+ }
106
+ // Search in custom agents
107
+ const customAgent = settings.customAgents.find(
108
+ (agent) => agent.id === agentId,
109
+ );
110
+ return customAgent || null;
111
+ }
112
+
113
+ /**
114
+ * Build AgentConfig with API key injection for known agents.
115
+ */
116
+ export function buildAgentConfigWithApiKey(
117
+ settings: AgentClientPluginSettings,
118
+ agentSettings: BaseAgentSettings,
119
+ agentId: string,
120
+ workingDirectory: string,
121
+ ) {
122
+ const baseConfig = toAgentConfig(agentSettings, workingDirectory);
123
+
124
+ // Add API keys to environment for Claude, Codex, and Gemini
125
+ if (agentId === settings.claude.id) {
126
+ const claudeSettings = agentSettings as ClaudeAgentSettings;
127
+ return {
128
+ ...baseConfig,
129
+ env: {
130
+ ...baseConfig.env,
131
+ ANTHROPIC_API_KEY: claudeSettings.apiKey,
132
+ },
133
+ };
134
+ }
135
+ if (agentId === settings.codex.id) {
136
+ const codexSettings = agentSettings as CodexAgentSettings;
137
+ return {
138
+ ...baseConfig,
139
+ env: {
140
+ ...baseConfig.env,
141
+ OPENAI_API_KEY: codexSettings.apiKey,
142
+ },
143
+ };
144
+ }
145
+ if (agentId === settings.gemini.id) {
146
+ const geminiSettings = agentSettings as GeminiAgentSettings;
147
+ return {
148
+ ...baseConfig,
149
+ env: {
150
+ ...baseConfig.env,
151
+ GEMINI_API_KEY: geminiSettings.apiKey,
152
+ },
153
+ };
154
+ }
155
+
156
+ // Custom agents - no API key injection
157
+ return baseConfig;
158
+ }
159
+
160
+ // ============================================================================
161
+ // Initial State
162
+ // ============================================================================
163
+
164
+ /**
165
+ * Create initial session state.
166
+ */
167
+ export function createInitialSession(
168
+ agentId: string,
169
+ agentDisplayName: string,
170
+ workingDirectory: string,
171
+ ): ChatSession {
172
+ return {
173
+ sessionId: null,
174
+ state: "disconnected",
175
+ agentId,
176
+ agentDisplayName,
177
+ authMethods: [],
178
+ availableCommands: undefined,
179
+ modes: undefined,
180
+ models: undefined,
181
+ createdAt: new Date(),
182
+ lastActivityAt: new Date(),
183
+ workingDirectory,
184
+ };
185
+ }
186
+
187
+ // ============================================================================
188
+ // Gemini CLI Deprecation Notice
189
+ // ============================================================================
190
+
191
+ /** Docs URL for the Gemini CLI deprecation announcement. */
192
+ export const GEMINI_DEPRECATION_DOCS_URL =
193
+ "https://rait-09.github.io/obsidian-agent-client/announcements/gemini-cli-deprecation.html";
194
+
195
+ /**
196
+ * Build the in-app notice shown while the Gemini CLI agent is selected.
197
+ *
198
+ * Google is retiring Gemini CLI for account-login (Pro/Ultra/free) tiers on
199
+ * June 18, 2026. This notice is static (no network) and is driven purely by the
200
+ * active agent id, unlike the npm-registry-backed agent update check.
201
+ */
202
+ export function buildGeminiDeprecationNotice(): AgentUpdateNotification {
203
+ return {
204
+ variant: "info",
205
+ title: "Gemini CLI is being discontinued",
206
+ message:
207
+ "Google is retiring account login for Gemini CLI (Pro/Ultra/free tiers) on June 18, 2026. " +
208
+ "Google states Gemini CLI stays accessible via a paid Gemini API key — see the guide for setup and privacy notes.",
209
+ link: { text: "Learn more", url: GEMINI_DEPRECATION_DOCS_URL },
210
+ };
211
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Pure functions for session state updates.
3
+ *
4
+ * These functions are extracted from useSession to keep the hook thin
5
+ * and to allow independent testing. They handle session config restoration
6
+ * and legacy mode/model management.
7
+ */
8
+
9
+ import type {
10
+ ChatSession,
11
+ SessionConfigOption,
12
+ SessionResult,
13
+ } from "../types/session";
14
+ import { flattenConfigSelectOptions } from "../types/session";
15
+ import type { AcpClient } from "../acp/acp-client";
16
+
17
+ // ============================================================================
18
+ // Legacy Config Helpers
19
+ // ============================================================================
20
+
21
+ /**
22
+ * Apply a legacy mode/model value to the session state.
23
+ * Used for both optimistic updates and rollbacks.
24
+ */
25
+ export function applyLegacyValue(
26
+ prev: ChatSession,
27
+ kind: "mode" | "model",
28
+ value: string,
29
+ ): ChatSession {
30
+ if (kind === "mode") {
31
+ if (!prev.modes) return prev;
32
+ return { ...prev, modes: { ...prev.modes, currentModeId: value } };
33
+ }
34
+ if (!prev.models) return prev;
35
+ return { ...prev, models: { ...prev.models, currentModelId: value } };
36
+ }
37
+
38
+ // ============================================================================
39
+ // Config Restore Helpers
40
+ // ============================================================================
41
+
42
+ /**
43
+ * Try to restore a saved config option value by category.
44
+ * Returns updated configOptions if restored, or the original if unchanged.
45
+ */
46
+ export async function tryRestoreConfigOption(
47
+ agentClient: AcpClient,
48
+ sessionId: string,
49
+ configOptions: SessionConfigOption[],
50
+ category: string,
51
+ savedValue: string | undefined,
52
+ ): Promise<SessionConfigOption[]> {
53
+ if (!savedValue) return configOptions;
54
+
55
+ const option = configOptions.find((o) => o.category === category);
56
+ if (!option) return configOptions;
57
+ if (savedValue === option.currentValue) return configOptions;
58
+ if (
59
+ !flattenConfigSelectOptions(option.options).some(
60
+ (o) => o.value === savedValue,
61
+ )
62
+ )
63
+ return configOptions;
64
+
65
+ try {
66
+ return await agentClient.setSessionConfigOption(
67
+ sessionId,
68
+ option.id,
69
+ savedValue,
70
+ );
71
+ } catch {
72
+ return configOptions;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Restore last used mode/model via legacy APIs.
78
+ * Only called when configOptions is not available.
79
+ */
80
+ export async function restoreLegacyConfig(
81
+ agentClient: AcpClient,
82
+ sessionResult: SessionResult,
83
+ savedModelId: string | undefined,
84
+ savedModeId: string | undefined,
85
+ setSession: (updater: (prev: ChatSession) => ChatSession) => void,
86
+ ): Promise<void> {
87
+ if (!sessionResult.sessionId) return;
88
+
89
+ // Legacy model restore
90
+ if (sessionResult.models && savedModelId) {
91
+ if (
92
+ savedModelId !== sessionResult.models.currentModelId &&
93
+ sessionResult.models.availableModels.some(
94
+ (m) => m.modelId === savedModelId,
95
+ )
96
+ ) {
97
+ try {
98
+ await agentClient.setSessionModel(
99
+ sessionResult.sessionId,
100
+ savedModelId,
101
+ );
102
+ setSession((prev) =>
103
+ applyLegacyValue(prev, "model", savedModelId),
104
+ );
105
+ } catch {
106
+ // Agent default is fine as fallback
107
+ }
108
+ }
109
+ }
110
+
111
+ // Legacy mode restore
112
+ if (sessionResult.modes && savedModeId) {
113
+ if (
114
+ savedModeId !== sessionResult.modes.currentModeId &&
115
+ sessionResult.modes.availableModes.some((m) => m.id === savedModeId)
116
+ ) {
117
+ try {
118
+ await agentClient.setSessionMode(
119
+ sessionResult.sessionId,
120
+ savedModeId,
121
+ );
122
+ setSession((prev) =>
123
+ applyLegacyValue(prev, "mode", savedModeId),
124
+ );
125
+ } catch {
126
+ // Agent default is fine as fallback
127
+ }
128
+ }
129
+ }
130
+ }