thepopebot 1.2.76-beta.4 → 1.2.76-beta.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.
package/bin/cli.js CHANGED
@@ -305,6 +305,38 @@ async function init() {
305
305
  console.log(' Created .claude/skills → ../skills/active');
306
306
  }
307
307
 
308
+ // Create .codex/skills → ../skills/active symlink
309
+ const codexSkillsLink = path.join(cwd, '.codex', 'skills');
310
+ if (!fs.existsSync(codexSkillsLink)) {
311
+ fs.mkdirSync(path.dirname(codexSkillsLink), { recursive: true });
312
+ createDirLink('../skills/active', codexSkillsLink);
313
+ console.log(' Created .codex/skills → ../skills/active');
314
+ }
315
+
316
+ // Create .gemini/skills → ../skills/active symlink
317
+ const geminiSkillsLink = path.join(cwd, '.gemini', 'skills');
318
+ if (!fs.existsSync(geminiSkillsLink)) {
319
+ fs.mkdirSync(path.dirname(geminiSkillsLink), { recursive: true });
320
+ createDirLink('../skills/active', geminiSkillsLink);
321
+ console.log(' Created .gemini/skills → ../skills/active');
322
+ }
323
+
324
+ // Create .kimi/skills → ../skills/active symlink
325
+ const kimiSkillsLink = path.join(cwd, '.kimi', 'skills');
326
+ if (!fs.existsSync(kimiSkillsLink)) {
327
+ fs.mkdirSync(path.dirname(kimiSkillsLink), { recursive: true });
328
+ createDirLink('../skills/active', kimiSkillsLink);
329
+ console.log(' Created .kimi/skills → ../skills/active');
330
+ }
331
+
332
+ // Create .agents/skills → ../skills/active symlink
333
+ const agentsSkillsLink = path.join(cwd, '.agents', 'skills');
334
+ if (!fs.existsSync(agentsSkillsLink)) {
335
+ fs.mkdirSync(path.dirname(agentsSkillsLink), { recursive: true });
336
+ createDirLink('../skills/active', agentsSkillsLink);
337
+ console.log(' Created .agents/skills → ../skills/active');
338
+ }
339
+
308
340
  // Report backed-up files
309
341
  if (backedUp.length > 0) {
310
342
  console.log(`\n Backed up ${backedUp.length} file(s) to ${path.relative(cwd, backupDir)}/`);
@@ -0,0 +1 @@
1
+ ALTER TABLE `code_workspaces` ADD `coding_agent` text;
@@ -148,6 +148,13 @@
148
148
  "when": 1774327178886,
149
149
  "tag": "0020_natural_fabian_cortez",
150
150
  "breakpoints": true
151
+ },
152
+ {
153
+ "idx": 21,
154
+ "version": "6",
155
+ "when": 1775865600000,
156
+ "tag": "0021_coding_agent_workspace",
157
+ "breakpoints": true
151
158
  }
152
159
  ]
153
160
  }
package/lib/ai/index.js CHANGED
@@ -185,7 +185,7 @@ async function* chatStream(threadId, message, attachments = [], options = {}) {
185
185
  await ensureWorkspaceRepo({ workspaceDir: repoDir, repo, branch, featureBranch });
186
186
  ensureSkills(repoDir, isCodeMode ? 'code' : 'agent');
187
187
  if (needsSetup) {
188
- const result = JSON.stringify({ result: `Workspace ready on ${featureBranch || branch}` });
188
+ const result = `Workspace ready on ${featureBranch || branch}`;
189
189
  yield { type: 'tool-result', toolCallId: setupToolCallId, result };
190
190
  persistMessage(threadId, 'assistant', JSON.stringify({
191
191
  type: 'tool-invocation',
@@ -198,7 +198,7 @@ async function* chatStream(threadId, message, attachments = [], options = {}) {
198
198
  }
199
199
  } catch (err) {
200
200
  if (needsSetup) {
201
- yield { type: 'tool-result', toolCallId: setupToolCallId, result: JSON.stringify({ result: `Setup failed: ${err.message}` }) };
201
+ yield { type: 'tool-result', toolCallId: setupToolCallId, result: `Setup failed: ${err.message}` };
202
202
  }
203
203
  throw err;
204
204
  }
@@ -98,6 +98,7 @@ export async function* claudeCodeStream({ prompt, workspaceDir, systemPrompt, se
98
98
  cwd: workspaceDir,
99
99
  env,
100
100
  includePartialMessages: true,
101
+ model: getConfig('CODING_AGENT_CLAUDE_CODE_MODEL') || undefined,
101
102
  };
102
103
 
103
104
  // Permission mode → allowed tools
@@ -133,6 +134,7 @@ export async function* claudeCodeStream({ prompt, workspaceDir, systemPrompt, se
133
134
 
134
135
  // Track tool call state for mapping stream events
135
136
  const activeToolCalls = new Map(); // index → { id, name, argsJson }
137
+ const activeThinkingBlocks = new Set(); // indices of active thinking blocks
136
138
 
137
139
  try {
138
140
  for await (const message of query({ prompt: sdkPrompt, options })) {
@@ -156,8 +158,11 @@ export async function* claudeCodeStream({ prompt, workspaceDir, systemPrompt, se
156
158
  if (block.type === 'tool_use') {
157
159
  activeToolCalls.set(event.index, { id: block.id, name: block.name, argsJson: '' });
158
160
  yield { type: 'tool-call', toolCallId: block.id, toolName: block.name, args: {} };
161
+ } else if (block.type === 'thinking') {
162
+ activeThinkingBlocks.add(event.index);
163
+ yield { type: 'thinking-start' };
159
164
  }
160
- // Skip 'thinking', 'text' start (deltas handle text)
165
+ // Skip 'text' start (deltas handle text)
161
166
  continue;
162
167
  }
163
168
 
@@ -167,11 +172,17 @@ export async function* claudeCodeStream({ prompt, workspaceDir, systemPrompt, se
167
172
  } else if (event.delta.type === 'input_json_delta') {
168
173
  const tc = activeToolCalls.get(event.index);
169
174
  if (tc) tc.argsJson += event.delta.partial_json;
175
+ } else if (event.delta.type === 'thinking_delta') {
176
+ yield { type: 'thinking', delta: event.delta.thinking };
170
177
  }
171
178
  continue;
172
179
  }
173
180
 
174
181
  if (event.type === 'content_block_stop') {
182
+ if (activeThinkingBlocks.has(event.index)) {
183
+ activeThinkingBlocks.delete(event.index);
184
+ yield { type: 'thinking-end' };
185
+ }
175
186
  const tc = activeToolCalls.get(event.index);
176
187
  if (tc && tc.argsJson) {
177
188
  try {
@@ -7,7 +7,6 @@ import {
7
7
  getChatByWorkspaceId,
8
8
  getMessagesByChatId,
9
9
  deleteChat as dbDeleteChat,
10
- deleteAllChatsByUser,
11
10
  updateChatTitle,
12
11
  toggleChatStarred,
13
12
  } from '../db/chats.js';
@@ -139,15 +138,6 @@ export async function starChat(chatId) {
139
138
  return { success: true, starred };
140
139
  }
141
140
 
142
- /**
143
- * Delete all chats for the authenticated user.
144
- * @returns {Promise<{success: boolean}>}
145
- */
146
- export async function deleteAllChats() {
147
- const user = await requireAuth();
148
- deleteAllChatsByUser(user.id);
149
- return { success: true };
150
- }
151
141
 
152
142
  /**
153
143
  * Get notifications, newest first, with pagination.
@@ -722,6 +712,66 @@ export async function getCodingAgentSettings() {
722
712
  }
723
713
  }
724
714
 
715
+ /**
716
+ * Return the list of coding agents that are enabled and have valid credentials.
717
+ * Used to populate the right-click agent picker on the Interactive toggle.
718
+ * @returns {Promise<Array<{value: string, label: string}>>}
719
+ */
720
+ export async function getAvailableCodingAgents() {
721
+ await requireAuth();
722
+ try {
723
+ const settings = await getCodingAgentSettings();
724
+ if (settings.error) return [];
725
+
726
+ const statusMap = new Map((settings.credentialStatuses || []).map(s => [s.key, s.isSet]));
727
+
728
+ function isProviderReady(provider) {
729
+ const builtin = settings.builtinProviders?.[provider];
730
+ if (builtin?.credentialKey) return statusMap.get(builtin.credentialKey) || false;
731
+ // Custom providers store their own credentials — if selected they're ready
732
+ const custom = (settings.customProviders || []).find(p => p.id === provider);
733
+ return !!custom;
734
+ }
735
+
736
+ const available = [];
737
+
738
+ if (settings.claudeCode?.enabled) {
739
+ const { claudeCode } = settings;
740
+ const backend = claudeCode.backend || 'anthropic';
741
+ const ready = backend === 'anthropic'
742
+ ? (claudeCode.auth === 'oauth' ? claudeCode.oauthTokenCount > 0 : claudeCode.anthropicKeySet)
743
+ : isProviderReady(backend);
744
+ if (ready) available.push({ value: 'claude-code', label: 'Claude Code' });
745
+ }
746
+
747
+ if (settings.pi?.enabled && settings.pi?.provider && isProviderReady(settings.pi.provider)) {
748
+ available.push({ value: 'pi-coding-agent', label: 'Pi' });
749
+ }
750
+
751
+ if (settings.geminiCli?.enabled && settings.geminiCli?.googleKeySet) {
752
+ available.push({ value: 'gemini-cli', label: 'Gemini CLI' });
753
+ }
754
+
755
+ if (settings.codexCli?.enabled) {
756
+ const { codexCli } = settings;
757
+ const ready = codexCli.auth === 'oauth' ? codexCli.oauthTokenCount > 0 : codexCli.codexKeySet;
758
+ if (ready) available.push({ value: 'codex-cli', label: 'Codex CLI' });
759
+ }
760
+
761
+ if (settings.openCode?.enabled && settings.openCode?.provider && isProviderReady(settings.openCode.provider)) {
762
+ available.push({ value: 'opencode', label: 'OpenCode' });
763
+ }
764
+
765
+ if (settings.kimiCli?.enabled && settings.kimiCli?.provider && isProviderReady(settings.kimiCli.provider)) {
766
+ available.push({ value: 'kimi-cli', label: 'Kimi CLI' });
767
+ }
768
+
769
+ return available;
770
+ } catch {
771
+ return [];
772
+ }
773
+ }
774
+
725
775
  /**
726
776
  * Update per-agent coding agent config.
727
777
  * @param {string} agent - 'claude-code' or 'pi-coding-agent'
package/lib/chat/api.js CHANGED
@@ -94,6 +94,28 @@ export async function POST(request) {
94
94
 
95
95
  let textStarted = false;
96
96
  let textId = uuidv4();
97
+ // Ephemeral thinking block state — tunneled as __thinking__ tool calls.
98
+ // Content is never persisted to DB (not a real tool-call/result pair in chatStream).
99
+ let thinkingId = null;
100
+ let thinkingText = '';
101
+ // Track which toolCallIds have had tool-input-start emitted.
102
+ //
103
+ // Two problems this solves:
104
+ //
105
+ // 1. The Claude Agent SDK emits tool-call twice per tool use: once at
106
+ // content_block_start (args: {}) and again at content_block_stop
107
+ // (args: complete). Sending tool-input-start twice for the same ID
108
+ // resets the AI SDK's internal part state to input-streaming and
109
+ // clears its stored input, causing a visual flicker. Deduplicate here.
110
+ //
111
+ // 2. When Claude Code resumes a session, the adapter skips assistant
112
+ // messages (to avoid duplicate UI) but still emits tool-result chunks
113
+ // for tool_result blocks in subsequent user messages. Those tool-result
114
+ // chunks have no matching tool-call in this stream, so tool-input-start
115
+ // is never sent for them. The AI SDK then throws
116
+ // "tool-output-error must be preceded by a tool-input-available"
117
+ // when tool-output-available arrives. Emit the open events defensively.
118
+ const openedToolCalls = new Set();
97
119
 
98
120
  for await (const chunk of chunks) {
99
121
  if (chunk.type === 'text') {
@@ -105,17 +127,7 @@ export async function POST(request) {
105
127
  writer.write({ type: 'text-delta', id: textId, delta: chunk.text });
106
128
 
107
129
  } else if (chunk.type === 'tool-call') {
108
- // Close any open text block before tool events
109
- if (textStarted) {
110
- writer.write({ type: 'text-end', id: textId });
111
- textStarted = false;
112
- }
113
- writer.write({
114
- type: 'tool-input-start',
115
- toolCallId: chunk.toolCallId,
116
- toolName: chunk.toolName,
117
- });
118
- // Enrich coding_agent input with active agent identity from config
130
+ // Enrich coding_agent input with active agent identity (LangGraph fallback path)
119
131
  let input = chunk.args;
120
132
  if (chunk.toolName === 'coding_agent') {
121
133
  const agent = getConfig('CODING_AGENT') || 'claude-code';
@@ -130,6 +142,21 @@ export async function POST(request) {
130
142
  const backendApi = getConfig(providerKeys[agent]) || 'anthropic';
131
143
  input = { ...chunk.args, codingAgent: agent, backendApi };
132
144
  }
145
+ if (!openedToolCalls.has(chunk.toolCallId)) {
146
+ // First time seeing this ID — open the tool block
147
+ if (textStarted) {
148
+ writer.write({ type: 'text-end', id: textId });
149
+ textStarted = false;
150
+ }
151
+ writer.write({
152
+ type: 'tool-input-start',
153
+ toolCallId: chunk.toolCallId,
154
+ toolName: chunk.toolName,
155
+ });
156
+ openedToolCalls.add(chunk.toolCallId);
157
+ }
158
+ // Always emit tool-input-available: first call shows empty args while
159
+ // streaming, second call (content_block_stop) updates to complete args
133
160
  writer.write({
134
161
  type: 'tool-input-available',
135
162
  toolCallId: chunk.toolCallId,
@@ -138,14 +165,26 @@ export async function POST(request) {
138
165
  });
139
166
 
140
167
  } else if (chunk.type === 'tool-result') {
141
- // Update input with complete args (accumulated from all streaming chunks)
142
- if (chunk.args) {
168
+ if (!openedToolCalls.has(chunk.toolCallId)) {
169
+ // tool-result arrived with no preceding tool-call in this stream
170
+ // (session resume replays tool results from skipped assistant messages).
171
+ // Emit the required open events so the AI SDK does not throw.
172
+ if (textStarted) {
173
+ writer.write({ type: 'text-end', id: textId });
174
+ textStarted = false;
175
+ }
176
+ writer.write({
177
+ type: 'tool-input-start',
178
+ toolCallId: chunk.toolCallId,
179
+ toolName: chunk.toolName || 'unknown',
180
+ });
143
181
  writer.write({
144
182
  type: 'tool-input-available',
145
183
  toolCallId: chunk.toolCallId,
146
- toolName: chunk.toolName,
147
- input: chunk.args,
184
+ toolName: chunk.toolName || 'unknown',
185
+ input: chunk.args || {},
148
186
  });
187
+ openedToolCalls.add(chunk.toolCallId);
149
188
  }
150
189
  writer.write({
151
190
  type: 'tool-output-available',
@@ -153,6 +192,44 @@ export async function POST(request) {
153
192
  output: chunk.result,
154
193
  });
155
194
 
195
+ } else if (chunk.type === 'thinking-start') {
196
+ // Open a new ephemeral thinking block as a pseudo-tool
197
+ if (textStarted) {
198
+ writer.write({ type: 'text-end', id: textId });
199
+ textStarted = false;
200
+ }
201
+ thinkingId = uuidv4();
202
+ thinkingText = '';
203
+ writer.write({
204
+ type: 'tool-input-start',
205
+ toolCallId: thinkingId,
206
+ toolName: '__thinking__',
207
+ });
208
+
209
+ } else if (chunk.type === 'thinking') {
210
+ // Accumulate and stream thinking deltas progressively
211
+ if (thinkingId) {
212
+ thinkingText += chunk.delta;
213
+ writer.write({
214
+ type: 'tool-input-available',
215
+ toolCallId: thinkingId,
216
+ toolName: '__thinking__',
217
+ input: thinkingText,
218
+ });
219
+ }
220
+
221
+ } else if (chunk.type === 'thinking-end') {
222
+ // Close the thinking block — empty output marks it done
223
+ if (thinkingId) {
224
+ writer.write({
225
+ type: 'tool-output-available',
226
+ toolCallId: thinkingId,
227
+ output: '',
228
+ });
229
+ thinkingId = null;
230
+ thinkingText = '';
231
+ }
232
+
156
233
  } else if (chunk.type === 'meta' || chunk.type === 'result') {
157
234
  // Internal events — no SSE output needed
158
235
 
@@ -449,6 +526,28 @@ export async function getRepositoriesHandler() {
449
526
  }
450
527
  }
451
528
 
529
+ /**
530
+ * POST handler for /code/repositories/create — create a new GitHub repository.
531
+ */
532
+ export async function createRepositoryHandler(request) {
533
+ const session = await auth();
534
+ if (!session?.user?.id) {
535
+ return Response.json({ error: 'Unauthorized' }, { status: 401 });
536
+ }
537
+ try {
538
+ const { name } = await request.json();
539
+ if (!name || typeof name !== 'string') {
540
+ return Response.json({ error: 'Repository name is required' }, { status: 400 });
541
+ }
542
+ const { createRepository } = await import('../tools/github.js');
543
+ const repo = await createRepository(name.trim());
544
+ return Response.json(repo);
545
+ } catch (err) {
546
+ const message = err.message || 'Failed to create repository';
547
+ return Response.json({ error: message }, { status: 422 });
548
+ }
549
+ }
550
+
452
551
  /**
453
552
  * GET handler for /code/branches?repo=owner/name — list branches with session auth.
454
553
  */
@@ -65,8 +65,10 @@ function ChatInput({ input, setInput, onSubmit, status, stop, files, setFiles, d
65
65
  const fileInputRef = useRef(null);
66
66
  const [isDragging, setIsDragging] = useState(false);
67
67
  const [modeDropdownOpen, setModeDropdownOpen] = useState(false);
68
+ const [agentPickerOpen, setAgentPickerOpen] = useState(false);
68
69
  const [partialText, setPartialText] = useState("");
69
70
  const dropdownRef = useRef(null);
71
+ const agentPickerRef = useRef(null);
70
72
  const isStreaming = status === "streaming" || status === "submitted";
71
73
  const volumeRef = useRef(0);
72
74
  const { voiceAvailable, isConnecting, isRecording, startRecording, stopRecording } = useVoiceInput({
@@ -106,6 +108,23 @@ function ChatInput({ input, setInput, onSubmit, status, stop, files, setFiles, d
106
108
  document.addEventListener("mousedown", handleClickOutside);
107
109
  return () => document.removeEventListener("mousedown", handleClickOutside);
108
110
  }, [modeDropdownOpen]);
111
+ useEffect(() => {
112
+ if (!agentPickerOpen) return;
113
+ const handleClickOutside = (e) => {
114
+ if (agentPickerRef.current && !agentPickerRef.current.contains(e.target)) {
115
+ setAgentPickerOpen(false);
116
+ }
117
+ };
118
+ const handleKeyDown2 = (e) => {
119
+ if (e.key === "Escape") setAgentPickerOpen(false);
120
+ };
121
+ document.addEventListener("mousedown", handleClickOutside);
122
+ document.addEventListener("keydown", handleKeyDown2);
123
+ return () => {
124
+ document.removeEventListener("mousedown", handleClickOutside);
125
+ document.removeEventListener("keydown", handleKeyDown2);
126
+ };
127
+ }, [agentPickerOpen]);
109
128
  const handleFiles = useCallback((fileList) => {
110
129
  const newFiles = Array.from(fileList).filter(isAcceptedType);
111
130
  if (newFiles.length === 0) return;
@@ -296,40 +315,65 @@ function ChatInput({ input, setInput, onSubmit, status, stop, files, setFiles, d
296
315
  )
297
316
  ] })
298
317
  ] }),
299
- codeModeSettings && !codeModeSettings.isInteractiveActive && /* @__PURE__ */ jsxs(
300
- "button",
301
- {
302
- type: "button",
303
- onClick: codeModeSettings.onInteractiveToggle,
304
- disabled: codeModeSettings.togglingMode || codeModeSettings.isInteractiveActive,
305
- className: "inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors",
306
- children: [
307
- codeModeSettings.togglingMode && /* @__PURE__ */ jsxs("svg", { className: "animate-spin h-3 w-3", xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", children: [
308
- /* @__PURE__ */ jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }),
309
- /* @__PURE__ */ jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" })
310
- ] }),
311
- /* @__PURE__ */ jsx(
312
- "span",
313
- {
314
- className: cn(
315
- "relative inline-flex h-3.5 w-6 shrink-0 rounded-full transition-colors duration-200",
316
- codeModeSettings.isInteractiveActive ? "bg-primary" : "bg-muted-foreground/30"
317
- ),
318
- children: /* @__PURE__ */ jsx(
319
- "span",
320
- {
321
- className: cn(
322
- "absolute top-0.5 left-0.5 h-2.5 w-2.5 rounded-full bg-white shadow-sm transition-transform duration-200",
323
- codeModeSettings.isInteractiveActive && "translate-x-2.5"
324
- )
325
- }
326
- )
318
+ codeModeSettings && !codeModeSettings.isInteractiveActive && /* @__PURE__ */ jsxs("div", { className: "relative", ref: agentPickerRef, children: [
319
+ agentPickerOpen && codeModeSettings.availableAgents?.length > 1 && /* @__PURE__ */ jsxs("div", { className: "absolute bottom-full left-0 mb-1.5 z-50 min-w-[140px] rounded-md border border-border bg-background shadow-md py-1 overflow-hidden", children: [
320
+ /* @__PURE__ */ jsx("p", { className: "px-3 pt-0.5 pb-1 text-[10px] font-medium text-muted-foreground uppercase tracking-wide", children: "Launch with" }),
321
+ codeModeSettings.availableAgents.map((agent) => /* @__PURE__ */ jsx(
322
+ "button",
323
+ {
324
+ type: "button",
325
+ onClick: () => {
326
+ setAgentPickerOpen(false);
327
+ codeModeSettings.onInteractiveToggle(agent.value);
328
+ },
329
+ className: "w-full text-left px-3 py-1.5 text-xs text-foreground hover:bg-muted transition-colors",
330
+ children: agent.label
331
+ },
332
+ agent.value
333
+ ))
334
+ ] }),
335
+ /* @__PURE__ */ jsxs(
336
+ "button",
337
+ {
338
+ type: "button",
339
+ onClick: () => codeModeSettings.onInteractiveToggle(),
340
+ onContextMenu: (e) => {
341
+ e.preventDefault();
342
+ if (!codeModeSettings.togglingMode && codeModeSettings.availableAgents?.length > 1) {
343
+ setAgentPickerOpen((prev) => !prev);
327
344
  }
328
- ),
329
- codeModeSettings.togglingMode ? "Launching..." : "Interactive"
330
- ]
331
- }
332
- ),
345
+ },
346
+ disabled: codeModeSettings.togglingMode || codeModeSettings.isInteractiveActive,
347
+ title: codeModeSettings.availableAgents?.length > 1 ? "Left-click to launch \xB7 Right-click to pick agent" : void 0,
348
+ className: "inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors",
349
+ children: [
350
+ codeModeSettings.togglingMode && /* @__PURE__ */ jsxs("svg", { className: "animate-spin h-3 w-3", xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", children: [
351
+ /* @__PURE__ */ jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }),
352
+ /* @__PURE__ */ jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" })
353
+ ] }),
354
+ /* @__PURE__ */ jsx(
355
+ "span",
356
+ {
357
+ className: cn(
358
+ "relative inline-flex h-3.5 w-6 shrink-0 rounded-full transition-colors duration-200",
359
+ codeModeSettings.isInteractiveActive ? "bg-primary" : "bg-muted-foreground/30"
360
+ ),
361
+ children: /* @__PURE__ */ jsx(
362
+ "span",
363
+ {
364
+ className: cn(
365
+ "absolute top-0.5 left-0.5 h-2.5 w-2.5 rounded-full bg-white shadow-sm transition-transform duration-200",
366
+ codeModeSettings.isInteractiveActive && "translate-x-2.5"
367
+ )
368
+ }
369
+ )
370
+ }
371
+ ),
372
+ codeModeSettings.togglingMode ? "Launching..." : "Interactive"
373
+ ]
374
+ }
375
+ )
376
+ ] }),
333
377
  /* @__PURE__ */ jsx(
334
378
  "input",
335
379
  {
@@ -46,8 +46,10 @@ export function ChatInput({ input, setInput, onSubmit, status, stop, files, setF
46
46
  const fileInputRef = useRef(null);
47
47
  const [isDragging, setIsDragging] = useState(false);
48
48
  const [modeDropdownOpen, setModeDropdownOpen] = useState(false);
49
+ const [agentPickerOpen, setAgentPickerOpen] = useState(false);
49
50
  const [partialText, setPartialText] = useState('');
50
51
  const dropdownRef = useRef(null);
52
+ const agentPickerRef = useRef(null);
51
53
  const isStreaming = status === 'streaming' || status === 'submitted';
52
54
  const volumeRef = useRef(0);
53
55
 
@@ -94,6 +96,25 @@ export function ChatInput({ input, setInput, onSubmit, status, stop, files, setF
94
96
  return () => document.removeEventListener('mousedown', handleClickOutside);
95
97
  }, [modeDropdownOpen]);
96
98
 
99
+ // Close agent picker on outside click or Escape
100
+ useEffect(() => {
101
+ if (!agentPickerOpen) return;
102
+ const handleClickOutside = (e) => {
103
+ if (agentPickerRef.current && !agentPickerRef.current.contains(e.target)) {
104
+ setAgentPickerOpen(false);
105
+ }
106
+ };
107
+ const handleKeyDown = (e) => {
108
+ if (e.key === 'Escape') setAgentPickerOpen(false);
109
+ };
110
+ document.addEventListener('mousedown', handleClickOutside);
111
+ document.addEventListener('keydown', handleKeyDown);
112
+ return () => {
113
+ document.removeEventListener('mousedown', handleClickOutside);
114
+ document.removeEventListener('keydown', handleKeyDown);
115
+ };
116
+ }, [agentPickerOpen]);
117
+
97
118
  const handleFiles = useCallback((fileList) => {
98
119
  const newFiles = Array.from(fileList).filter(isAcceptedType);
99
120
  if (newFiles.length === 0) return;
@@ -303,35 +324,64 @@ export function ChatInput({ input, setInput, onSubmit, status, stop, files, setF
303
324
  </div>
304
325
  )}
305
326
 
306
- {/* Interactive toggle */}
327
+ {/* Interactive toggle — left-click to launch with default agent,
328
+ right-click to pick a specific agent (when multiple are available) */}
307
329
  {codeModeSettings && !codeModeSettings.isInteractiveActive && (
308
- <button
309
- type="button"
310
- onClick={codeModeSettings.onInteractiveToggle}
311
- disabled={codeModeSettings.togglingMode || codeModeSettings.isInteractiveActive}
312
- className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
313
- >
314
- {codeModeSettings.togglingMode && (
315
- <svg className="animate-spin h-3 w-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
316
- <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
317
- <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
318
- </svg>
330
+ <div className="relative" ref={agentPickerRef}>
331
+ {/* Agent picker popup — appears above the toggle on right-click */}
332
+ {agentPickerOpen && codeModeSettings.availableAgents?.length > 1 && (
333
+ <div className="absolute bottom-full left-0 mb-1.5 z-50 min-w-[140px] rounded-md border border-border bg-background shadow-md py-1 overflow-hidden">
334
+ <p className="px-3 pt-0.5 pb-1 text-[10px] font-medium text-muted-foreground uppercase tracking-wide">Launch with</p>
335
+ {codeModeSettings.availableAgents.map(agent => (
336
+ <button
337
+ key={agent.value}
338
+ type="button"
339
+ onClick={() => {
340
+ setAgentPickerOpen(false);
341
+ codeModeSettings.onInteractiveToggle(agent.value);
342
+ }}
343
+ className="w-full text-left px-3 py-1.5 text-xs text-foreground hover:bg-muted transition-colors"
344
+ >
345
+ {agent.label}
346
+ </button>
347
+ ))}
348
+ </div>
319
349
  )}
320
- <span
321
- className={cn(
322
- 'relative inline-flex h-3.5 w-6 shrink-0 rounded-full transition-colors duration-200',
323
- codeModeSettings.isInteractiveActive ? 'bg-primary' : 'bg-muted-foreground/30'
324
- )}
350
+ <button
351
+ type="button"
352
+ onClick={() => codeModeSettings.onInteractiveToggle()}
353
+ onContextMenu={(e) => {
354
+ e.preventDefault();
355
+ if (!codeModeSettings.togglingMode && codeModeSettings.availableAgents?.length > 1) {
356
+ setAgentPickerOpen(prev => !prev);
357
+ }
358
+ }}
359
+ disabled={codeModeSettings.togglingMode || codeModeSettings.isInteractiveActive}
360
+ title={codeModeSettings.availableAgents?.length > 1 ? 'Left-click to launch · Right-click to pick agent' : undefined}
361
+ className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
325
362
  >
363
+ {codeModeSettings.togglingMode && (
364
+ <svg className="animate-spin h-3 w-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
365
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
366
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
367
+ </svg>
368
+ )}
326
369
  <span
327
370
  className={cn(
328
- 'absolute top-0.5 left-0.5 h-2.5 w-2.5 rounded-full bg-white shadow-sm transition-transform duration-200',
329
- codeModeSettings.isInteractiveActive && 'translate-x-2.5'
371
+ 'relative inline-flex h-3.5 w-6 shrink-0 rounded-full transition-colors duration-200',
372
+ codeModeSettings.isInteractiveActive ? 'bg-primary' : 'bg-muted-foreground/30'
330
373
  )}
331
- />
332
- </span>
333
- {codeModeSettings.togglingMode ? 'Launching...' : 'Interactive'}
334
- </button>
374
+ >
375
+ <span
376
+ className={cn(
377
+ 'absolute top-0.5 left-0.5 h-2.5 w-2.5 rounded-full bg-white shadow-sm transition-transform duration-200',
378
+ codeModeSettings.isInteractiveActive && 'translate-x-2.5'
379
+ )}
380
+ />
381
+ </span>
382
+ {codeModeSettings.togglingMode ? 'Launching...' : 'Interactive'}
383
+ </button>
384
+ </div>
335
385
  )}
336
386
 
337
387
  <input