Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "codeg",
"private": true,
"version": "0.21.5",
"version": "0.21.6",
"packageManager": "[email protected]",
"scripts": {
"dev": "next dev --turbopack",
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "codeg"
version = "0.21.5"
version = "0.21.6"
description = "Agent Code Generation App"
authors = ["feitao"]
edition = "2021"
Expand Down
4 changes: 2 additions & 2 deletions src-tauri/src/acp/delegation/tool_schema.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[
{
"name": "delegate_to_agent",
"description": "Hand off a self-contained sub-task to a separate local AI agent that runs in its own session. ASYNCHRONOUS: returns a task_id right away and the sub-agent keeps working in the background — this call never blocks. So you can fan out several delegations at once and keep working, then collect the results with get_delegation_status by passing the task_ids array (one id to poll a single task, or many at once to poll the whole fan-out in one call) — or stop one early with cancel_delegation(task_id). The sub-agent CANNOT see this conversation, your open files, or earlier turns — it starts cold, so `task` must carry everything it needs. Best for independent, parallelizable work you can describe up front; not for steps that need your ongoing back-and-forth.",
"description": "Hand off a self-contained sub-task to a separate local AI agent that runs in its own session. ASYNCHRONOUS: returns a task_id right away and the sub-agent keeps working in the background — this call never blocks. So you can fan out several delegations at once and keep working, then collect the results with get_delegation_status by passing the task_ids array (one id to poll a single task, or many at once to poll the whole fan-out in one call) — or stop one early with cancel_delegation(task_id). The sub-agent CANNOT see this conversation, your open files, or earlier turns — it starts cold, so `task` must carry everything it needs. Best for independent, parallelizable work you can describe up front; not for steps that need your ongoing back-and-forth. RECOGNIZING AN EXPLICIT DELEGATION REQUEST: the user can name a sub-agent directly in their message — it appears as `@AgentName` or as a Markdown link `[@AgentName](codeg://agent/<agent_type>)`. Such a mention IS an explicit instruction to delegate the associated work to that agent, even when the user never names this tool. If the message names several agents, make one call per agent, each carrying that agent's slice of the work.",
"inputSchema": {
"type": "object",
"required": ["agent_type", "task"],
Expand All @@ -22,7 +22,7 @@
"grok",
"cursor"
],
"description": "Which local agent runs the sub-task. Pick the one best suited to the work."
"description": "Which local agent runs the sub-task. Pick the one best suited to the work — or, when the user's message names an agent via `@AgentName` / `codeg://agent/<agent_type>`, use that exact `<agent_type>` slug (e.g. `codeg://agent/claude_code` means `claude_code`)."
},
"task": {
"type": "string",
Expand Down
60 changes: 30 additions & 30 deletions src-tauri/src/acp/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,8 +194,8 @@ pub fn get_agent_meta(agent_type: AgentType) -> AcpAgentMeta {
name: "Claude Code",
description: "ACP wrapper for Anthropic's Claude",
distribution: AgentDistribution::Npx {
version: "0.60.0",
package: "@agentclientprotocol/claude-agent-acp@0.60.0",
version: "0.61.0",
package: "@agentclientprotocol/claude-agent-acp@0.61.0",
cmd: "claude-agent-acp",
args: &[],
env: &[],
Expand Down Expand Up @@ -231,8 +231,8 @@ pub fn get_agent_meta(agent_type: AgentType) -> AcpAgentMeta {
name: "Gemini CLI",
description: "Google's official CLI for Gemini",
distribution: AgentDistribution::Npx {
version: "0.51.0",
package: "@google/gemini-cli@0.51.0",
version: "0.52.0",
package: "@google/gemini-cli@0.52.0",
cmd: "gemini",
args: &["--acp", "--skip-trust"],
env: &[],
Expand Down Expand Up @@ -336,8 +336,8 @@ pub fn get_agent_meta(agent_type: AgentType) -> AcpAgentMeta {
name: "CodeBuddy",
description: "Tencent Cloud's official AI coding assistant (ACP)",
distribution: AgentDistribution::Npx {
version: "2.125.0",
package: "@tencent-ai/codebuddy-code@2.125.0",
version: "2.126.0",
package: "@tencent-ai/codebuddy-code@2.126.0",
cmd: "codebuddy",
args: &["--acp"],
env: &[],
Expand All @@ -350,8 +350,8 @@ pub fn get_agent_meta(agent_type: AgentType) -> AcpAgentMeta {
name: "Kimi Code",
description: "Moonshot AI's official CLI coding assistant (ACP)",
distribution: AgentDistribution::Npx {
version: "0.28.1",
package: "@moonshot-ai/kimi-code@0.28.1",
version: "0.29.0",
package: "@moonshot-ai/kimi-code@0.29.0",
cmd: "kimi",
args: &["acp"],
env: &[],
Expand Down Expand Up @@ -407,8 +407,8 @@ pub fn get_agent_meta(agent_type: AgentType) -> AcpAgentMeta {
// leading `KEY=value` argv and sacp's `parse_env_var` only accepts
// `[A-Za-z0-9_]` env names, which npm's `@scope:registry` key is not.)
distribution: AgentDistribution::Npx {
version: "0.2.103",
package: "@xai-official/[email protected].103",
version: "0.2.111",
package: "@xai-official/[email protected].111",
cmd: "grok",
// Only the ACP subcommand lives here. Grok's ROOT-level launch
// flags (`--no-auto-update` always, `--permission-mode <value>`
Expand All @@ -419,7 +419,7 @@ pub fn get_agent_meta(agent_type: AgentType) -> AcpAgentMeta {
// args rather than appending after.
args: &["agent", "stdio"],
env: &[],
// `@xai-official/[email protected].103` declares `engines.node: ">=20"`;
// `@xai-official/[email protected].111` declares `engines.node: ">=20"`;
// surface that in preflight so Node 18 isn't silently accepted.
node_required: Some("20.0.0"),
},
Expand All @@ -441,34 +441,34 @@ pub fn get_agent_meta(agent_type: AgentType) -> AcpAgentMeta {
// (downloads.cursor.com/lab/<version>/<os>/<arch>/...); custom
// versions substitute into the same pattern.
distribution: AgentDistribution::Binary {
version: "2026.07.16-899851b",
version: "2026.07.20-8cc9c0b",
cmd: "cursor-agent",
args: &["acp"],
env: &[],
platforms: &[
PlatformBinary {
platform: "darwin-aarch64",
url: "https://downloads.cursor.com/lab/2026.07.16-899851b/darwin/arm64/agent-cli-package.tar.gz",
url: "https://downloads.cursor.com/lab/2026.07.20-8cc9c0b/darwin/arm64/agent-cli-package.tar.gz",
},
PlatformBinary {
platform: "darwin-x86_64",
url: "https://downloads.cursor.com/lab/2026.07.16-899851b/darwin/x64/agent-cli-package.tar.gz",
url: "https://downloads.cursor.com/lab/2026.07.20-8cc9c0b/darwin/x64/agent-cli-package.tar.gz",
},
PlatformBinary {
platform: "linux-aarch64",
url: "https://downloads.cursor.com/lab/2026.07.16-899851b/linux/arm64/agent-cli-package.tar.gz",
url: "https://downloads.cursor.com/lab/2026.07.20-8cc9c0b/linux/arm64/agent-cli-package.tar.gz",
},
PlatformBinary {
platform: "linux-x86_64",
url: "https://downloads.cursor.com/lab/2026.07.16-899851b/linux/x64/agent-cli-package.tar.gz",
url: "https://downloads.cursor.com/lab/2026.07.20-8cc9c0b/linux/x64/agent-cli-package.tar.gz",
},
PlatformBinary {
platform: "windows-aarch64",
url: "https://downloads.cursor.com/lab/2026.07.16-899851b/windows/arm64/agent-cli-package.zip",
url: "https://downloads.cursor.com/lab/2026.07.20-8cc9c0b/windows/arm64/agent-cli-package.zip",
},
PlatformBinary {
platform: "windows-x86_64",
url: "https://downloads.cursor.com/lab/2026.07.16-899851b/windows/x64/agent-cli-package.zip",
url: "https://downloads.cursor.com/lab/2026.07.20-8cc9c0b/windows/x64/agent-cli-package.zip",
},
],
dir_entry: Some(BinaryDirEntry {
Expand Down Expand Up @@ -572,8 +572,8 @@ mod tests {
let meta = get_agent_meta(AgentType::Cursor);
assert_binary_version(
AgentType::Cursor,
"2026.07.16-899851b",
"/lab/2026.07.16-899851b/",
"2026.07.20-8cc9c0b",
"/lab/2026.07.20-8cc9c0b/",
);
match meta.distribution {
AgentDistribution::Binary {
Expand Down Expand Up @@ -603,14 +603,14 @@ mod tests {
fn registry_pins_current_acp_agent_versions() {
assert_npx_version(
AgentType::ClaudeCode,
"0.60.0",
"@agentclientprotocol/claude-agent-acp@0.60.0",
"0.61.0",
"@agentclientprotocol/claude-agent-acp@0.61.0",
Some("22.0.0"),
);
assert_npx_version(
AgentType::Gemini,
"0.51.0",
"@google/gemini-cli@0.51.0",
"0.52.0",
"@google/gemini-cli@0.52.0",
Some("20.0.0"),
);
assert_npx_version(
Expand All @@ -627,14 +627,14 @@ mod tests {
);
assert_npx_version(
AgentType::CodeBuddy,
"2.125.0",
"@tencent-ai/codebuddy-code@2.125.0",
"2.126.0",
"@tencent-ai/codebuddy-code@2.126.0",
Some("22.0.0"),
);
assert_npx_version(
AgentType::KimiCode,
"0.28.1",
"@moonshot-ai/kimi-code@0.28.1",
"0.29.0",
"@moonshot-ai/kimi-code@0.29.0",
Some("22.19.0"),
);
assert_npx_version(
Expand All @@ -646,8 +646,8 @@ mod tests {
assert_npx_version(AgentType::Pi, "0.0.31", "[email protected]", Some("22.0.0"));
assert_npx_version(
AgentType::Grok,
"0.2.103",
"@xai-official/[email protected].103",
"0.2.111",
"@xai-official/[email protected].111",
Some("20.0.0"),
);
assert_binary_version(AgentType::OpenCode, "1.18.4", "/releases/download/v1.18.4/");
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/commands/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -604,7 +604,7 @@ async fn detect_local_version(agent_type: AgentType) -> Option<String> {
}
// Dir-tree agents: a user-installed CLI (no codeg cache) still
// reports a version via `<cmd> --version` (e.g. cursor-agent →
// "2026.07.16-899851b").
// "2026.07.20-8cc9c0b").
if dir_entry.is_some() {
return system_dir_agent_version(cmd).await;
}
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "codeg",
"version": "0.21.5",
"version": "0.21.6",
"identifier": "app.codeg",
"build": {
"beforeDevCommand": "pnpm tauri:before-dev",
Expand Down
125 changes: 125 additions & 0 deletions src/components/message/virtualized-message-thread.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import type { ReactNode } from "react"
import { fireEvent, render, screen } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"

const testState = vi.hoisted(() => ({
scrollRef: { current: null as HTMLDivElement | null },
}))

vi.mock("use-stick-to-bottom", () => ({
useStickToBottomContext: () => ({ scrollRef: testState.scrollRef }),
}))

vi.mock("virtua", () => ({
Virtualizer: ({ children }: { children: ReactNode }) => <>{children}</>,
}))

vi.mock("@/components/ai-elements/message-thread", () => ({
MessageThreadContent: ({
children,
scrollClassName,
}: {
children: ReactNode
scrollClassName?: string
}) => (
<div
ref={(element) => {
testState.scrollRef.current = element
}}
className={scrollClassName}
data-testid="viewport"
>
{children}
</div>
),
}))

import { VirtualizedMessageThread } from "@/components/message/virtualized-message-thread"

function renderThread(
content: ReactNode = <div data-testid="content">text</div>
) {
return render(
<VirtualizedMessageThread
items={[{ id: "message-1" }]}
getItemKey={(item) => item.id}
renderItem={() => content}
/>
)
}

function pointerDown(element: HTMLElement, button: number) {
fireEvent(element, new MouseEvent("pointerdown", { bubbles: true, button }))
}

function keyDown(element: HTMLElement, key: string) {
fireEvent.keyDown(element, { key })
}

beforeEach(() => {
testState.scrollRef.current = null
})

describe("VirtualizedMessageThread focus origin", () => {
it("marks pointer-origin focus and clears it on blur", () => {
renderThread()
const viewport = screen.getByTestId("viewport")

pointerDown(screen.getByTestId("content"), 0)

expect(document.activeElement).toBe(viewport)
expect(viewport).toHaveAttribute("data-focus-origin", "pointer")
expect(viewport.className).toContain(
"data-[focus-origin=pointer]:focus-visible:ring-0"
)

fireEvent.blur(viewport)
expect(viewport).not.toHaveAttribute("data-focus-origin")
})

it("clears the pointer marker on keyboard input so the ring returns", () => {
renderThread()
const viewport = screen.getByTestId("viewport")

pointerDown(screen.getByTestId("content"), 0)
expect(viewport).toHaveAttribute("data-focus-origin", "pointer")

// Switching to keyboard scrolling drops the marker, so the suppressing
// `data-[focus-origin=pointer]` selector no longer matches and the
// keyboard focus ring becomes visible again.
keyDown(viewport, "ArrowDown")
expect(viewport).not.toHaveAttribute("data-focus-origin")
expect(document.activeElement).toBe(viewport)
})

it("keeps keyboard-origin focus distinguishable", () => {
renderThread()
const viewport = screen.getByTestId("viewport")

viewport.focus()

expect(document.activeElement).toBe(viewport)
expect(viewport).not.toHaveAttribute("data-focus-origin")
expect(viewport.className).toContain("focus-visible:ring-2")
})

it("does not mark focus when an interactive control is clicked", () => {
renderThread(<button data-testid="action">Action</button>)
const viewport = screen.getByTestId("viewport")

pointerDown(screen.getByTestId("action"), 0)

expect(viewport).not.toHaveAttribute("data-focus-origin")
expect(document.activeElement).not.toBe(viewport)
})

it("does not mark focus for a right click", () => {
renderThread()
const viewport = screen.getByTestId("viewport")

pointerDown(screen.getByTestId("content"), 2)

expect(viewport).not.toHaveAttribute("data-focus-origin")
expect(document.activeElement).not.toBe(viewport)
})
})
20 changes: 18 additions & 2 deletions src/components/message/virtualized-message-thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ function VirtualizedMessageThreadImpl<T>({
const el = scrollRef.current
if (!el) return
el.tabIndex = 0
const clearPointerFocus = () => {
el.removeAttribute("data-focus-origin")
}
const onPointerDown = (e: PointerEvent) => {
// Ignore right-click and macOS ctrl-click (both open the context menu).
if (e.button !== 0 || e.ctrlKey) return
Expand All @@ -117,10 +120,23 @@ function VirtualizedMessageThreadImpl<T>({
)
)
return
el.setAttribute("data-focus-origin", "pointer")
el.focus({ preventScroll: true })
}
el.addEventListener("pointerdown", onPointerDown)
return () => el.removeEventListener("pointerdown", onPointerDown)
el.addEventListener("blur", clearPointerFocus)
// Once the user drives the viewport with the keyboard (Arrow/Page/Home/End
// to scroll), drop the pointer-origin marker so the focus ring reappears —
// keeping the keyboard focus indicator visible per WCAG 2.4.7. The ring is
// only suppressed for the mouse click that focused the viewport, not for
// subsequent keyboard use.
el.addEventListener("keydown", clearPointerFocus)
return () => {
el.removeEventListener("pointerdown", onPointerDown)
el.removeEventListener("blur", clearPointerFocus)
el.removeEventListener("keydown", clearPointerFocus)
clearPointerFocus()
}
}, [scrollRef])

// Pre-compute the three possible padding styles so every render reuses
Expand All @@ -146,7 +162,7 @@ function VirtualizedMessageThreadImpl<T>({
<MessageScrollProvider value={scrollContextValue}>
<MessageThreadContent
className={cn("mx-0 max-w-none p-0", contentClassName)}
scrollClassName="scrollbar-thin overscroll-contain [overflow-anchor:none] outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
scrollClassName="scrollbar-thin overscroll-contain [overflow-anchor:none] outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset data-[focus-origin=pointer]:focus-visible:ring-0"
{...contentProps}
>
{items.length === 0 ? (
Expand Down
Loading