Skip to content
Draft
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ NeverWrite is an agentic markdown workspace for people who work with a local vau

- **Write in the format that fits.** Edit Markdown, Mermaid, CSV, text/code files, PDFs, images, and Excalidraw concept maps in the same workspace.
- **Navigate connected knowledge.** Follow wikilinks, backlinks, tags, advanced search, bookmarks, and 2D or 3D graph views.
- **Work with your preferred agent.** Run Codex, Claude, Grok, Kilo, or OpenCode sessions with attachments, saved transcripts, and local history.
- **Work with your preferred agent.** Run Codex, Claude, GitHub Copilot, Grok, Kilo, or OpenCode sessions with attachments, saved transcripts, and local history.
- **Review AI changes deliberately.** Inspect tracked edits inline, in chat, or in a dedicated review tab, then keep or reject complete files and individual hunks.
- **Capture the web into your vault.** Use the companion browser extension to clip pages, selections, or URLs directly to the desktop app.

Expand Down
279 changes: 271 additions & 8 deletions apps/desktop/native-backend/src/ai.rs

Large diffs are not rendered by default.

22 changes: 20 additions & 2 deletions apps/desktop/native-backend/src/runtime_catalog.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use neverwrite_ai::{
custom_runtimes::CustomAcpRuntimeDefinition, CLAUDE_RUNTIME_ID, CODEX_RUNTIME_ID,
GROK_RUNTIME_ID, KILO_RUNTIME_ID, OPENCODE_RUNTIME_ID,
COPILOT_RUNTIME_ID, GROK_RUNTIME_ID, KILO_RUNTIME_ID, OPENCODE_RUNTIME_ID,
};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand Down Expand Up @@ -180,6 +180,7 @@ impl<'a> RuntimeCatalogView<'a> {
}

const NO_ACP_ARGS: &[&str] = &[];
const COPILOT_ACP_ARGS: &[&str] = &["--acp"];
const GROK_ACP_ARGS: &[&str] = &["--no-auto-update", "agent", "stdio"];
const SHELL_ACP_ARGS: &[&str] = &["acp"];

Expand All @@ -204,6 +205,16 @@ const BUILT_IN_RUNTIME_DEFINITIONS: &[BuiltInRuntimeDefinition] = &[
acp_protocol: AcpProtocolFlavor::Current,
supports_native_resume: false,
},
BuiltInRuntimeDefinition {
id: COPILOT_RUNTIME_ID,
name: "GitHub Copilot",
description: "GitHub Copilot CLI running as a native ACP agent.",
default_executable: "copilot",
bin_env_var: "NEVERWRITE_COPILOT_ACP_BIN",
acp_args: COPILOT_ACP_ARGS,
acp_protocol: AcpProtocolFlavor::Current,
supports_native_resume: false,
},
BuiltInRuntimeDefinition {
id: GROK_RUNTIME_ID,
name: "Grok",
Expand Down Expand Up @@ -275,6 +286,7 @@ mod tests {
[
CODEX_RUNTIME_ID,
CLAUDE_RUNTIME_ID,
COPILOT_RUNTIME_ID,
GROK_RUNTIME_ID,
KILO_RUNTIME_ID,
OPENCODE_RUNTIME_ID,
Expand Down Expand Up @@ -321,6 +333,12 @@ mod tests {
Some("NEVERWRITE_CLAUDE_ACP_BIN"),
Vec::new(),
),
(
COPILOT_RUNTIME_ID,
"copilot",
Some("NEVERWRITE_COPILOT_ACP_BIN"),
vec!["--acp".to_string()],
),
(
GROK_RUNTIME_ID,
"grok",
Expand Down Expand Up @@ -353,7 +371,7 @@ mod tests {
let catalog = RUNTIME_CATALOG.with_custom(&custom);
let definitions = catalog.definitions().collect::<Vec<_>>();

assert_eq!(definitions.len(), 6);
assert_eq!(definitions.len(), 7);
let custom = catalog.definition(&custom[0].id).unwrap();
assert!(custom.is_custom());
assert_eq!(custom.name(), "Local agent");
Expand Down
45 changes: 43 additions & 2 deletions apps/desktop/scripts/smoke-electron-ai-runtime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ if (customCapturePath) {
pid: process.pid
}));
}
const isCopilot = process.argv.includes("--acp");
function send(message) {
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\\n");
}
Expand All @@ -234,6 +235,20 @@ function option(id, name) {
return { value: id, name };
}
function configOptions(mode = "default") {
if (isCopilot) {
return [
{
id: "mode", name: "Mode", category: "mode", type: "select",
currentValue: "https://agentclientprotocol.com/protocol/session-modes#agent",
options: [
option("https://agentclientprotocol.com/protocol/session-modes#agent", "Agent"),
option("https://agentclientprotocol.com/protocol/session-modes#plan", "Plan"),
option("https://agentclientprotocol.com/protocol/session-modes#autopilot", "Autopilot")
]
},
{ id: "allow_all", name: "Allow All", category: "other", type: "select", currentValue: "off", options: [option("off", "Off"), option("on", "On")] }
];
}
return [
{
id: "mode",
Expand Down Expand Up @@ -314,8 +329,12 @@ createInterface({ input: process.stdin }).on("line", (line) => {
availableModels: [{ modelId: "auto", name: "Auto" }]
},
modes: {
currentModeId: "default",
availableModes: [
currentModeId: isCopilot ? "https://agentclientprotocol.com/protocol/session-modes#agent" : "default",
availableModes: isCopilot ? [
{ id: "https://agentclientprotocol.com/protocol/session-modes#agent", name: "Agent" },
{ id: "https://agentclientprotocol.com/protocol/session-modes#plan", name: "Plan" },
{ id: "https://agentclientprotocol.com/protocol/session-modes#autopilot", name: "Autopilot" }
] : [
{ id: "default", name: "Default" },
{ id: "review", name: "Review" }
]
Expand Down Expand Up @@ -644,6 +663,7 @@ async function main() {
for (const runtimeId of [
"codex-acp",
"claude-acp",
"copilot-acp",
"grok-acp",
"kilo-acp",
"opencode-acp",
Expand Down Expand Up @@ -1295,6 +1315,27 @@ async function main() {
"real ACP stream completion",
);

const copilotSetup = await client.invoke("ai_update_setup", {
runtimeId: "copilot-acp",
input: { custom_binary_path: fakeAcpPath },
});
assert(copilotSetup.binary_ready === true, "Copilot should resolve the fake ACP binary");
const copilotSession = await client.invoke("ai_create_session", {
input: { runtime_id: "copilot-acp", additional_roots: null },
vaultPath,
});
assert(
copilotSession.modes.some((mode) => mode.name === "Agent") &&
copilotSession.modes.some((mode) => mode.name === "Plan") &&
copilotSession.modes.some((mode) => mode.name === "Autopilot"),
"Copilot ACP modes should preserve their negotiated IDs",
);
const allowAll = copilotSession.config_options.find((option) => option.id === "allow_all");
assert(allowAll?.value === "off", "Copilot Allow All must remain disabled by default");
await client.invoke("ai_set_config_option", {
input: { session_id: copilotSession.session_id, option_id: "allow_all", value: "on" },
});

await fs.writeFile(fakeAcpRequestLogPath, "");
await client.invoke("ai_update_setup", {
runtimeId: "claude-acp",
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/features/ai/store/chatStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2038,6 +2038,7 @@ function isAuthenticationErrorMessage(

const normalized = message.trim().toLowerCase();
const isOpenCodeRuntime = runtimeId === "opencode-acp";
const isCopilotRuntime = runtimeId === "copilot-acp";
const isOpenCodeAuthGuidance =
normalized.includes("run opencode auth login") ||
normalized.includes("use /connect") ||
Expand All @@ -2046,12 +2047,21 @@ function isAuthenticationErrorMessage(
normalized.includes("no provider configured") ||
normalized.includes("unauthorized") ||
normalized.includes("401")));
const isCopilotAuthGuidance =
normalized.includes("run copilot login") ||
normalized.includes("copilot login") ||
(isCopilotRuntime &&
(normalized.includes("login required") ||
normalized.includes("not authenticated") ||
normalized.includes("unauthorized") ||
normalized.includes("401")));

return (
normalized.includes("auth_required") ||
normalized.includes("authentication required") ||
normalized.includes("auth required") ||
isOpenCodeAuthGuidance ||
isCopilotAuthGuidance ||
normalized.includes("you were signed out") ||
normalized.includes("reconnect in ai setup") ||
normalized.includes("reconnect codex") ||
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/features/ai/utils/authMethods.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ describe("authMethods", () => {
expect(
isIntegratedTerminalAuthMethod("opencode-acp", "opencode-login"),
).toBe(true);
expect(
isIntegratedTerminalAuthMethod("copilot-acp", "copilot-login"),
).toBe(true);
});

it("rejects terminal auth methods for the wrong runtime", () => {
Expand All @@ -40,6 +43,7 @@ describe("authMethods", () => {
expect(isIntegratedTerminalAuthMethodId("grok-login")).toBe(true);
expect(isIntegratedTerminalAuthMethodId("kilo-login")).toBe(true);
expect(isIntegratedTerminalAuthMethodId("opencode-login")).toBe(true);
expect(isIntegratedTerminalAuthMethodId("copilot-login")).toBe(true);
expect(isIntegratedTerminalAuthMethodId("openai-api-key")).toBe(false);
});
});
7 changes: 6 additions & 1 deletion apps/desktop/src/features/ai/utils/authMethods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ export function isIntegratedTerminalAuthMethod(
return methodId === "opencode-login";
}

if (runtimeId === "copilot-acp") {
return methodId === "copilot-login";
}

return false;
}

Expand All @@ -40,6 +44,7 @@ export function isIntegratedTerminalAuthMethodId(methodId?: string) {
isClaudeTerminalAuthMethodId(methodId) ||
methodId === "grok-login" ||
methodId === "kilo-login" ||
methodId === "opencode-login"
methodId === "opencode-login" ||
methodId === "copilot-login"
);
}
12 changes: 12 additions & 0 deletions apps/desktop/src/features/ai/utils/runtimeMetadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ describe("runtimeMetadata", () => {
name: "Grok",
company: "xAI",
}),
expect.objectContaining({
id: "copilot-acp",
name: "GitHub Copilot",
company: "GitHub",
}),
]),
);
});
Expand Down Expand Up @@ -60,6 +65,12 @@ describe("runtimeMetadata", () => {
]),
}),
}),
expect.objectContaining({
runtime: expect.objectContaining({
id: "copilot-acp",
name: "GitHub Copilot ACP",
}),
}),
]),
);
});
Expand All @@ -80,6 +91,7 @@ describe("runtimeMetadata", () => {
expect(getRuntimeDisplayName("kilo-acp")).toBe("Kilo");
expect(getRuntimeDisplayName("grok-acp")).toBe("Grok");
expect(getRuntimeDisplayName("opencode-acp")).toBe("OpenCode");
expect(getRuntimeDisplayName("copilot-acp")).toBe("GitHub Copilot");
expect(getRuntimeDisplayName(undefined, undefined)).toBe("Assistant");
});
});
7 changes: 7 additions & 0 deletions apps/desktop/src/features/ai/utils/runtimeMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ const RUNTIME_METADATA: RuntimeMetadata[] = [
"prompt_queueing",
],
},
{
id: "copilot-acp",
name: "GitHub Copilot",
company: "GitHub",
description: "GitHub Copilot CLI running as a native ACP agent.",
capabilities: ["attachments", "permissions", "plans", "terminal_output", "create_session", "prompt_queueing", "user_input"],
},
{
id: "grok-acp",
name: "Grok",
Expand Down
24 changes: 21 additions & 3 deletions apps/desktop/src/features/settings/AIProvidersSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ import type {

const OPENCODE_RUNTIME_ID = "opencode-acp";
const OPENCODE_AUTH_METHOD_ID = "opencode-login";
const COPILOT_RUNTIME_ID = "copilot-acp";
const COPILOT_AUTH_METHOD_ID = "copilot-login";
const GROK_RUNTIME_ID = "grok-acp";
const CLAUDE_ACP_RUNTIME_ID = "claude-acp";
const GOOGLE_VERTEX_METHOD_ID = "google-vertex";
Expand Down Expand Up @@ -161,6 +163,7 @@ function getShortMethodDesc(id: string): string {
case "grok-login":
case "kilo-login":
case OPENCODE_AUTH_METHOD_ID:
case COPILOT_AUTH_METHOD_ID:
return "Terminal sign-in";
case "openai-api-key":
return "OpenAI API key";
Expand Down Expand Up @@ -199,6 +202,8 @@ function getAuthHelpText(id: string): string {
return "Opens a Kilo sign-in terminal inside the app.";
case OPENCODE_AUTH_METHOD_ID:
return "Use providers and credentials configured by the OpenCode CLI.";
case COPILOT_AUTH_METHOD_ID:
return "Open GitHub Copilot sign-in in an integrated terminal.";
case "openai-api-key":
return `Store an OpenAI API key locally for ${APP_BRAND_NAME} only.`;
case "codex-api-key":
Expand Down Expand Up @@ -241,6 +246,7 @@ function getActionLabel(
if (methodId === "grok-login") return "Open sign-in terminal";
if (methodId === "kilo-login") return "Open sign-in terminal";
if (methodId === OPENCODE_AUTH_METHOD_ID) return "Open sign-in terminal";
if (methodId === COPILOT_AUTH_METHOD_ID) return "Open sign-in terminal";
if (isApiKeyMethod(methodId)) {
return status.authReady && status.authMethod === methodId
? "Replace key"
Expand All @@ -251,11 +257,13 @@ function getActionLabel(
}

function getSecondaryAuthActionLabel(status: AIRuntimeSetupStatus): string {
return status.runtimeId === OPENCODE_RUNTIME_ID ? "Disconnect" : "Log Out";
return [OPENCODE_RUNTIME_ID, COPILOT_RUNTIME_ID].includes(status.runtimeId)
? "Disconnect"
: "Log Out";
}

function getLogoutErrorFallback(runtimeId: string): string {
return runtimeId === OPENCODE_RUNTIME_ID
return [OPENCODE_RUNTIME_ID, COPILOT_RUNTIME_ID].includes(runtimeId)
? "Failed to disconnect."
: "Failed to log out.";
}
Expand Down Expand Up @@ -313,7 +321,7 @@ function setSecretPatch(value: string): AISecretPatch {
}

function supportsRuntimeBinaryOverride(runtimeId: string): boolean {
return runtimeId === OPENCODE_RUNTIME_ID || runtimeId === GROK_RUNTIME_ID;
return [OPENCODE_RUNTIME_ID, GROK_RUNTIME_ID, COPILOT_RUNTIME_ID].includes(runtimeId);
}

function getRuntimeBinaryPlaceholder(runtimeId: string): string {
Expand All @@ -323,6 +331,9 @@ function getRuntimeBinaryPlaceholder(runtimeId: string): string {
if (runtimeId === GROK_RUNTIME_ID) {
return "Custom Grok runtime path, for example grok";
}
if (runtimeId === COPILOT_RUNTIME_ID) {
return "Custom GitHub Copilot runtime path, for example copilot";
}
return "Custom runtime path";
}

Expand All @@ -333,6 +344,9 @@ function getRuntimeBinaryHelpText(runtimeId: string): string {
if (runtimeId === GROK_RUNTIME_ID) {
return "Leave empty to use grok from PATH.";
}
if (runtimeId === COPILOT_RUNTIME_ID) {
return "Leave empty to use copilot from PATH.";
}
return "Leave empty to use the bundled runtime or PATH.";
}

Expand Down Expand Up @@ -420,6 +434,10 @@ function getProviderSearchValues(
provider.id === GROK_RUNTIME_ID
? "NEVERWRITE_GROK_ACP_BIN"
: undefined,
provider.id === COPILOT_RUNTIME_ID ? "copilot acp --acp" : undefined,
provider.id === COPILOT_RUNTIME_ID
? "NEVERWRITE_COPILOT_ACP_BIN"
: undefined,
getMethodDisplayName(setupStatus),
error,
...(setupStatus
Expand Down
1 change: 1 addition & 0 deletions crates/ai/src/domain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};

pub const CODEX_RUNTIME_ID: &str = "codex-acp";
pub const CLAUDE_RUNTIME_ID: &str = "claude-acp";
pub const COPILOT_RUNTIME_ID: &str = "copilot-acp";
pub const GROK_RUNTIME_ID: &str = "grok-acp";
pub const KILO_RUNTIME_ID: &str = "kilo-acp";
pub const OPENCODE_RUNTIME_ID: &str = "opencode-acp";
Expand Down
10 changes: 8 additions & 2 deletions crates/index/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -883,7 +883,10 @@ fn reindex_updates_status() {
"Note A",
serde_json::json!({ "status": "draft", "type": "article" }),
)]);
assert_eq!(index.metadata[&NoteId("a".into())].status.as_deref(), Some("draft"));
assert_eq!(
index.metadata[&NoteId("a".into())].status.as_deref(),
Some("draft")
);

index.reindex_note(make_note_with_frontmatter(
"a",
Expand All @@ -903,7 +906,10 @@ fn reindex_clears_removed_status() {
"Note A",
serde_json::json!({ "status": "draft" }),
)]);
assert_eq!(index.metadata[&NoteId("a".into())].status.as_deref(), Some("draft"));
assert_eq!(
index.metadata[&NoteId("a".into())].status.as_deref(),
Some("draft")
);

index.reindex_note(make_note_with_frontmatter(
"a",
Expand Down
Loading
Loading