From 537b6f66bbe14a37b557f6855228ec654e44915f Mon Sep 17 00:00:00 2001 From: gagan Date: Thu, 28 May 2026 14:52:09 +0530 Subject: [PATCH] Code Mode: in-chat toggle, settings tab, and permission/command UX (#572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add in-chat code mode toggle with claude/codex swap * feat: show agent and add swap-and-retry on acpx permission card * style: reorder permission card buttons (approve, deny, swap) * feat: add tooltips to composer plus and web search buttons * feat: add code mode settings tab with agent install/auth checks * feat: show sign-in command when agent installed but signed out * style: refine code-mode permission and command block UX - Render permission block before the command block - Collapse permission details after a response; click header to expand - Drop status icons/badge; use minimal green / bold red blocks - Auto-collapse the running command block once it completes * feat: rotating progress labels for code-mode commands; darker tool borders - Code-mode (acpx) command block shows status-aware labels: rotating 'Working on the task…' phrases (5s each, holding on the last) while running, then 'Completed the task' / "Couldn't complete the task" - Darken outer border on all tool blocks in light and dark modes * fix: detect Claude Code sign-in via macOS Keychain On macOS, Claude Code stores OAuth credentials in the login Keychain (service 'Claude Code-credentials'), not in ~/.claude/.credentials.json. Read the Keychain as a fallback so signed-in Mac users are detected. * feat: persistent per-chat sessions for code-mode coding agents - Use a named acpx session (rowboat-) per chat so follow-up coding requests resume the same agent and keep context - Create the session once at chat start (sessions new --name), then prompt with -s ; reuse on follow-ups (no re-create) - Drop the redundant in-chat 'reply yes' confirmation (the executeCommand permission card is the confirmation) - Code-mode output uses plain-text paths (overrides global filepath rule) - On not-installed/auth errors, point user to Settings -> Code Mode * fix: code-mode session creation uses idempotent ensure, run sequentially - Use 'sessions ensure --name' instead of 'sessions new' so reopening a chat resumes the existing session instead of erroring on a name clash - Create the session and run the prompt as separate sequential calls so the permission/command blocks render one at a time (not all at once) * fix: reliable Claude Code session resume on Windows (avoid claude.cmd EINVAL) Resuming a code-mode chat after restarting the app spawns a fresh ACP agent. On Windows + Node >=20.12 the bridge spawning claude.cmd throws EINVAL, so the session queue owner fails to start. Rowboat injects CLAUDE_CODE_EXECUTABLE=claude.exe to dodge this, but the override didn't reliably reach the spawn. Windows-only; no-op on macOS/Linux. - executeCommand now accepts an env override and the non-abortable fallback path passes it through (was silently dropped) - resolveClaudeExeOnWindows also scans known npm/pnpm/volta global bin dirs, not just PATH (Electron's runtime PATH can omit them) - add --timeout 600 to acpx prompt commands so a genuine stall fails cleanly instead of hanging on 'Running' forever --- apps/x/apps/main/src/ipc.ts | 19 +- apps/x/apps/renderer/src/App.tsx | 41 +++- .../ai-elements/ask-human-request.tsx | 79 ++++--- .../ai-elements/permission-request.tsx | 89 +++++--- .../components/chat-input-with-mentions.tsx | 187 ++++++++++++++-- .../renderer/src/components/chat-sidebar.tsx | 2 +- .../src/components/settings-dialog.tsx | 208 +++++++++++++++++- .../renderer/src/lib/chat-conversation.ts | 32 +++ apps/x/packages/core/src/agents/runtime.ts | 56 ++++- .../src/application/assistant/instructions.ts | 37 ++-- .../skills/code-with-agents/skill.ts | 160 +++++++++----- .../core/src/application/lib/builtin-tools.ts | 44 +++- .../src/application/lib/command-executor.ts | 2 + .../core/src/application/lib/message-queue.ts | 8 +- apps/x/packages/core/src/code-mode/index.ts | 3 + apps/x/packages/core/src/code-mode/repo.ts | 42 ++++ apps/x/packages/core/src/code-mode/status.ts | 199 +++++++++++++++++ apps/x/packages/core/src/code-mode/types.ts | 18 ++ apps/x/packages/core/src/di/container.ts | 2 + apps/x/packages/core/src/runs/runs.ts | 4 +- apps/x/packages/shared/src/ipc.ts | 22 ++ apps/x/packages/shared/src/runs.ts | 1 + 22 files changed, 1084 insertions(+), 171 deletions(-) create mode 100644 apps/x/packages/core/src/code-mode/index.ts create mode 100644 apps/x/packages/core/src/code-mode/repo.ts create mode 100644 apps/x/packages/core/src/code-mode/status.ts create mode 100644 apps/x/packages/core/src/code-mode/types.ts diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index ed8b1a7c..2f5730ce 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -31,6 +31,9 @@ import { listGatewayModels } from '@x/core/dist/models/gateway.js'; import type { IModelConfigRepo } from '@x/core/dist/models/repo.js'; import type { IOAuthRepo } from '@x/core/dist/auth/repo.js'; import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js'; +import { ICodeModeConfigRepo } from '@x/core/dist/code-mode/repo.js'; +import { checkCodeModeAgentStatus } from '@x/core/dist/code-mode/status.js'; +import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js'; import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js'; import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js'; import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js'; @@ -526,7 +529,7 @@ export function setupIpcHandlers() { return runsCore.createRun(args); }, 'runs:createMessage': async (_event, args) => { - return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext) }; + return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext, args.codeMode) }; }, 'runs:authorizePermission': async (_event, args) => { await runsCore.authorizePermission(args.runId, args.authorization); @@ -630,6 +633,20 @@ export function setupIpcHandlers() { const config = await repo.getConfig(); return { enabled: config.enabled }; }, + 'codeMode:getConfig': async () => { + const repo = container.resolve('codeModeConfigRepo'); + const config = await repo.getConfig(); + return { enabled: config.enabled }; + }, + 'codeMode:setConfig': async (_event, args) => { + const repo = container.resolve('codeModeConfigRepo'); + await repo.setConfig({ enabled: args.enabled }); + invalidateCopilotInstructionsCache(); + return { success: true }; + }, + 'codeMode:checkAgentStatus': async () => { + return await checkCodeModeAgentStatus(); + }, 'granola:setConfig': async (_event, args) => { const repo = container.resolve('granolaConfigRepo'); await repo.setConfig({ enabled: args.enabled }); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 3c653b1b..2bf0c571 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -966,7 +966,7 @@ function App() { voice.start() }, [voice]) - const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean) => Promise) | null>(null) + const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex') => Promise) | null>(null) const pendingVoiceInputRef = useRef(false) // Palette: per-tab editor handles for capturing cursor context on Cmd+K, and pending payload @@ -2190,6 +2190,19 @@ function App() { status: 'running', timestamp: Date.now(), }]) + // Detect acpx-driven coding-agent runs so the composer can retroactively + // flip code mode on with the right agent (when the user reached the skill + // via plain prompt rather than the explicit toggle). + if (llmEvent.toolName === 'executeCommand') { + const input = llmEvent.input as { command?: unknown } | undefined + const cmd = typeof input?.command === 'string' ? input.command : '' + const match = cmd.match(/\bacpx\b[\s\S]*?\b(claude|codex)\b/) + if (match) { + window.dispatchEvent(new CustomEvent('code-mode-detected', { + detail: { runId: event.runId, agent: match[1] as 'claude' | 'codex' }, + })) + } + } } else if (llmEvent.type === 'finish-step') { const nextUsage = normalizeUsage(llmEvent.usage) if (nextUsage) { @@ -2304,7 +2317,7 @@ function App() { return next }) - if (event.toolCallId && event.toolName !== 'executeCommand') { + if (event.toolCallId) { setToolOpenForTab(activeChatTabIdRef.current, event.toolCallId, false) } @@ -2482,6 +2495,7 @@ function App() { mentions?: FileMention[], stagedAttachments: StagedAttachment[] = [], searchEnabled?: boolean, + codeMode?: 'claude' | 'codex', ) => { if (isProcessing) return @@ -2593,6 +2607,7 @@ function App() { voiceInput: pendingVoiceInputRef.current || undefined, voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined, searchEnabled: searchEnabled || undefined, + codeMode: codeMode || undefined, middlePaneContext, }) analytics.chatMessageSent({ @@ -2608,6 +2623,7 @@ function App() { voiceInput: pendingVoiceInputRef.current || undefined, voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined, searchEnabled: searchEnabled || undefined, + codeMode: codeMode || undefined, middlePaneContext, }) analytics.chatMessageSent({ @@ -5836,7 +5852,6 @@ function App() { const response = tabState.permissionResponses.get(item.id) || null return ( - {rendered} handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')} onApproveAlways={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')} onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} + onSwitchAgent={async (newAgent) => { + const runIdForSwitch = tab.runId + await handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny') + window.dispatchEvent(new CustomEvent('code-mode-detected', { + detail: { runId: runIdForSwitch, agent: newAgent }, + })) + if (runIdForSwitch) { + try { + await window.ipc.invoke('runs:createMessage', { + runId: runIdForSwitch, + message: `Use ${newAgent === 'claude' ? 'Claude Code' : 'Codex'} instead — rerun the same task with the same prompt, just swap the agent binary to \`${newAgent}\`.`, + codeMode: newAgent, + }) + } catch (err) { + console.error('Failed to send swap-agent follow-up', err) + } + } + }} isProcessing={isActive && isProcessing} response={response} /> + {rendered} ) } @@ -5858,6 +5892,7 @@ function App() { handleAskHumanResponse(request.toolCallId, request.subflow, response)} isProcessing={isActive && isProcessing} /> diff --git a/apps/x/apps/renderer/src/components/ai-elements/ask-human-request.tsx b/apps/x/apps/renderer/src/components/ai-elements/ask-human-request.tsx index 2e92e2ca..6571e54e 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/ask-human-request.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/ask-human-request.tsx @@ -9,6 +9,7 @@ import { useState, useRef, useEffect } from "react"; export type AskHumanRequestProps = ComponentProps<"div"> & { query: string; + options?: string[]; onResponse: (response: string) => void; isProcessing?: boolean; }; @@ -16,17 +17,21 @@ export type AskHumanRequestProps = ComponentProps<"div"> & { export const AskHumanRequest = ({ className, query, + options, onResponse, isProcessing = false, ...props }: AskHumanRequestProps) => { const [response, setResponse] = useState(""); const textareaRef = useRef(null); + const hasOptions = Array.isArray(options) && options.length > 0; useEffect(() => { - // Auto-focus the textarea when component mounts - textareaRef.current?.focus(); - }, []); + // Auto-focus the textarea when in free-text mode; nothing to focus for buttons. + if (!hasOptions) { + textareaRef.current?.focus(); + } + }, [hasOptions]); const handleSubmit = () => { const trimmed = response.trim(); @@ -36,6 +41,11 @@ export const AskHumanRequest = ({ } }; + const handleOptionClick = (option: string) => { + if (isProcessing) return; + onResponse(option); + }; + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); @@ -65,30 +75,47 @@ export const AskHumanRequest = ({ {query}

-
-