diff --git a/README.md b/README.md index eb10d6366..30a92765f 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ It aggregates your sessions from every supported agent CLI into one searchable w ## 🤖 Supported Agents -Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness +Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness · Qoder Codeg installs, pins, and updates most of them for you. See [Supported Agents](https://docs.codeg.app/guide/supported-agents) for the full roster, each agent's runtime requirements, and where it keeps its sessions on disk. diff --git a/docs/readme/README.ar.md b/docs/readme/README.ar.md index 0be4a2305..0dd7f1e67 100644 --- a/docs/readme/README.ar.md +++ b/docs/readme/README.ar.md @@ -65,7 +65,7 @@ Codeg (Code Generation) هو مساحة عمل برمجية متعددة الو ## 🤖 الوكلاء المدعومون -Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness +Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness · Qoder يتولّى Codeg تثبيت معظمهم وتثبيت إصداراتهم وتحديثهم نيابةً عنك. راجع [الوكلاء المدعومون](https://docs.codeg.app/guide/supported-agents) للقائمة الكاملة، ومتطلبات تشغيل كل وكيل، وموضع حفظ جلساته على القرص. diff --git a/docs/readme/README.de.md b/docs/readme/README.de.md index 18e35bc01..49110b6ab 100644 --- a/docs/readme/README.de.md +++ b/docs/readme/README.de.md @@ -65,7 +65,7 @@ Codeg bündelt die Sitzungen aller unterstützten Agenten-CLIs in einem durchsuc ## 🤖 Unterstützte Agenten -Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness +Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness · Qoder Die meisten davon installiert, fixiert und aktualisiert Codeg für dich. Die vollständige Liste, die Laufzeit-Anforderungen jedes Agenten und den Ablageort seiner Sitzungen findest du unter [Unterstützte Agenten](https://docs.codeg.app/guide/supported-agents). diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index 743d7f264..e1fa5a865 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -65,7 +65,7 @@ Reúne las sesiones de todas las CLI de agentes compatibles en un único espacio ## 🤖 Agentes compatibles -Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness +Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness · Qoder Codeg instala, fija la versión y actualiza la mayoría de ellos por ti. Consulta [Agentes compatibles](https://docs.codeg.app/guide/supported-agents) para ver la lista completa, los requisitos de ejecución de cada uno y dónde guarda sus sesiones en disco. diff --git a/docs/readme/README.fr.md b/docs/readme/README.fr.md index a10e3f540..126d17875 100644 --- a/docs/readme/README.fr.md +++ b/docs/readme/README.fr.md @@ -65,7 +65,7 @@ Il regroupe les sessions de toutes les CLI d'agents supportées dans un espace d ## 🤖 Agents supportés -Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness +Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness · Qoder Codeg installe, épingle et met à jour la plupart d'entre eux pour vous. Voir [Agents supportés](https://docs.codeg.app/guide/supported-agents) pour la liste complète, les prérequis d'exécution de chacun et l'emplacement de ses sessions sur le disque. diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index 9fd575236..a16ab20b0 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -65,7 +65,7 @@ Codeg(Code Generation)はマルチエージェント・コーディングワ ## 🤖 対応エージェント -Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness +Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness · Qoder その多くは Codeg がインストール・バージョン固定・更新まで面倒を見ます。全リスト、各エージェントの実行環境要件、セッションの保存場所は [対応エージェント](https://docs.codeg.app/guide/supported-agents) を参照してください。 diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index 84fbc2ddd..b9b275336 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -65,7 +65,7 @@ Codeg(Code Generation)는 멀티 에이전트 코딩 워크스페이스입니다 ## 🤖 지원 에이전트 -Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness +Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness · Qoder 이 중 대부분은 Codeg가 대신 설치하고, 버전을 고정하고, 업데이트합니다. 전체 목록과 각 에이전트의 실행 환경 요구 사항, 세션이 디스크에 저장되는 위치는 [지원 에이전트](https://docs.codeg.app/guide/supported-agents)를 참고하세요. diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md index 816080e9c..b29df84b8 100644 --- a/docs/readme/README.pt.md +++ b/docs/readme/README.pt.md @@ -65,7 +65,7 @@ Ele agrega as sessões de todas as CLIs de agentes suportadas em um único espa ## 🤖 Agentes suportados -Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness +Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness · Qoder O Codeg instala, fixa a versão e atualiza a maioria deles por você. Veja [Agentes suportados](https://docs.codeg.app/guide/supported-agents) para a lista completa, os requisitos de execução de cada um e onde ele guarda as sessões em disco. diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 6d1e35ae3..4de7834ea 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -65,7 +65,7 @@ Codeg(Code Generation)是一个多智能体编码工作台:把所有 AI ## 🤖 支持的 Agent -Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness +Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness · Qoder 其中大部分 Codeg 都能替你安装、锁定版本并更新。完整名单、各自的运行环境要求以及会话在磁盘上的存放位置,见 [支持的智能体](https://docs.codeg.app/zh/guide/supported-agents)。 diff --git a/docs/readme/README.zh-TW.md b/docs/readme/README.zh-TW.md index ec6d7f5b3..0e243e3ae 100644 --- a/docs/readme/README.zh-TW.md +++ b/docs/readme/README.zh-TW.md @@ -65,7 +65,7 @@ Codeg(Code Generation)是一個多智慧體編碼工作台:把所有 AI ## 🤖 支援的 Agent -Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness +Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · CodeBuddy · Kimi Code · Pi · Grok · Cursor · DeepSeek Harness · Qoder 其中大部分 Codeg 都能替你安裝、鎖定版本並更新。完整名單、各自的執行環境需求以及工作階段在磁碟上的存放位置,見 [支援的智慧體](https://docs.codeg.app/zh/guide/supported-agents)。 diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 94123e900..111bd3d3c 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -3314,9 +3314,18 @@ fn load_mcp_servers_for_agent(agent_type: AgentType) -> Vec { // DeepSeek is deliberately NOT in this set: deepseek-acp reads no MCP file // at all, so `$DSH_HOME/mcp.json` (codeg's own store) reaches it ONLY // through the wire — skipping it would silently drop every user server. + // + // Qoder joins the skip set: the CLI reads `mcpServers` out of its own + // `~/.qoder/settings.json` (gemini-schema settings file) at startup, which + // codeg's MCP settings UI manages directly — forwarding the same servers + // over the wire would double-mount them. if matches!( agent_type, - AgentType::Hermes | AgentType::KimiCode | AgentType::Grok | AgentType::Cursor + AgentType::Hermes + | AgentType::KimiCode + | AgentType::Grok + | AgentType::Cursor + | AgentType::Qoder ) { return Vec::new(); } diff --git a/src-tauri/src/acp/custom_registry.rs b/src-tauri/src/acp/custom_registry.rs index 057ab7b47..8d4d2b0f0 100644 --- a/src-tauri/src/acp/custom_registry.rs +++ b/src-tauri/src/acp/custom_registry.rs @@ -969,11 +969,11 @@ mod tests { // A custom definition must not claim a BUILT-IN registry id: it would be // shadowed by `from_registry_id`'s built-in arms and double-listed by // `all_acp_agents`. This is also the hydration path that retires a - // pre-integration `deepseek-acp` custom entry (kept in the DB, never - // published) once the id became a built-in. + // pre-integration `deepseek-acp` / `qoder-cli` custom entry (kept in the + // DB, never published) once the id became a built-in. #[test] fn rejects_builtin_registry_id_collisions() { - for id in ["deepseek-acp", "claude-acp", "cursor"] { + for id in ["deepseek-acp", "qoder-cli", "claude-acp", "cursor"] { assert_eq!( validate(&npx_def(id)), Err(CustomAgentError::CollidesWithBuiltin(id.to_string())), diff --git a/src-tauri/src/acp/delegation/companion.rs b/src-tauri/src/acp/delegation/companion.rs index 8cf8eb0de..342307988 100644 --- a/src-tauri/src/acp/delegation/companion.rs +++ b/src-tauri/src/acp/delegation/companion.rs @@ -1597,7 +1597,7 @@ mod tests { let agents = delegate["inputSchema"]["properties"]["agent_type"]["enum"] .as_array() .unwrap(); - assert_eq!(agents.len(), 13); + assert_eq!(agents.len(), 14); assert!(agents.iter().any(|a| a == "hermes")); assert!(agents.iter().any(|a| a == "code_buddy")); assert!(agents.iter().any(|a| a == "kimi_code")); @@ -1605,6 +1605,7 @@ mod tests { assert!(agents.iter().any(|a| a == "grok")); assert!(agents.iter().any(|a| a == "cursor")); assert!(agents.iter().any(|a| a == "deepseek")); + assert!(agents.iter().any(|a| a == "qoder")); // get_delegation_status takes a single id param — task_ids (required) — // plus wait_ms. The legacy single `task_id` param is gone. let status = tools @@ -1644,11 +1645,11 @@ mod tests { .as_array() .unwrap() .clone(); - assert_eq!(agents.len(), 15, "13 builtins + 2 distinct customs"); + assert_eq!(agents.len(), 16, "14 builtins + 2 distinct customs"); // Builtins keep the embedded order and come first. assert_eq!(agents[0], "claude_code"); - assert_eq!(agents[13], "custom:goose"); - assert_eq!(agents[14], "custom:amp"); + assert_eq!(agents[14], "custom:goose"); + assert_eq!(agents[15], "custom:amp"); // The other delegation tools carry no agent_type and are untouched. let status = tools .as_array() @@ -1683,13 +1684,13 @@ mod tests { .as_array() .unwrap() .clone(); - assert_eq!(agents.len(), 12, "13 builtins - 2 disabled + 1 custom"); + assert_eq!(agents.len(), 13, "14 builtins - 2 disabled + 1 custom"); assert!(!agents.contains(&serde_json::json!("codex"))); assert!(!agents.contains(&serde_json::json!("grok"))); // Survivors keep the embedded order, customs still come last. assert_eq!(agents[0], "claude_code"); assert_eq!(agents[1], "open_code"); - assert_eq!(agents[11], "custom:goose"); + assert_eq!(agents[12], "custom:goose"); } // An empty disabled list (the parent omitted `--disabled-agents`) leaves @@ -1711,9 +1712,9 @@ mod tests { .as_array() .unwrap() .clone(); - assert_eq!(agents.len(), 13); + assert_eq!(agents.len(), 14); assert_eq!(agents[0], "claude_code"); - assert_eq!(agents[12], "deepseek"); + assert_eq!(agents[13], "qoder"); } #[tokio::test] diff --git a/src-tauri/src/acp/delegation/tool_schema.json b/src-tauri/src/acp/delegation/tool_schema.json index 08002aaf4..86231de9c 100644 --- a/src-tauri/src/acp/delegation/tool_schema.json +++ b/src-tauri/src/acp/delegation/tool_schema.json @@ -21,7 +21,8 @@ "pi", "grok", "cursor", - "deepseek" + "deepseek", + "qoder" ], "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/`, use that exact `` slug (e.g. `codeg://agent/claude_code` means `claude_code`)." }, diff --git a/src-tauri/src/acp/file_system_runtime.rs b/src-tauri/src/acp/file_system_runtime.rs index fcda55a80..64ff653ba 100644 --- a/src-tauri/src/acp/file_system_runtime.rs +++ b/src-tauri/src/acp/file_system_runtime.rs @@ -486,6 +486,14 @@ fn agent_root_slots(agent_type: AgentType) -> &'static [RootSlot] { trims: false, default_rel: &[".grok"], }], + // Qoder relocates its whole home via `QODER_CONFIG_DIR` (the env form + // of the CLI's `--config-dir`); the sessions tree lives under + // `projects/` inside it. + AgentType::Qoder => &[RootSlot { + candidates: &[("QODER_CONFIG_DIR", "")], + trims: false, + default_rel: &[".qoder"], + }], AgentType::ClaudeCode => &[RootSlot { candidates: &[("CLAUDE_CONFIG_DIR", "")], trims: false, diff --git a/src-tauri/src/acp/registry.rs b/src-tauri/src/acp/registry.rs index 878b56719..b214a42a4 100644 --- a/src-tauri/src/acp/registry.rs +++ b/src-tauri/src/acp/registry.rs @@ -138,7 +138,7 @@ pub fn current_platform() -> &'static str { } } -/// The thirteen built-in agents. Excludes user-registered custom agents — use +/// The fourteen built-in agents. Excludes user-registered custom agents — use /// [`all_acp_agents`] for the live set. pub fn builtin_acp_agents() -> Vec { vec![ @@ -155,10 +155,11 @@ pub fn builtin_acp_agents() -> Vec { AgentType::Grok, AgentType::Cursor, AgentType::DeepSeek, + AgentType::Qoder, ] } -/// Every agent codeg can currently drive: the thirteen built-ins followed by +/// Every agent codeg can currently drive: the fourteen built-ins followed by /// the user's registered custom ACP agents (sorted by id). pub fn all_acp_agents() -> Vec { let mut agents = builtin_acp_agents(); @@ -181,6 +182,7 @@ pub fn registry_id_for(agent_type: AgentType) -> &'static str { AgentType::Grok => "grok-build", AgentType::Cursor => "cursor", AgentType::DeepSeek => "deepseek-acp", + AgentType::Qoder => "qoder-cli", // A custom agent's registry id IS its identity. AgentType::Custom(id) => id, } @@ -201,6 +203,7 @@ pub fn from_registry_id(id: &str) -> Option { "grok-build" => Some(AgentType::Grok), "cursor" => Some(AgentType::Cursor), "deepseek-acp" => Some(AgentType::DeepSeek), + "qoder-cli" => Some(AgentType::Qoder), // Only ids the user has actually registered resolve. An unregistered // id must stay `None` so the ACP-registry picker still offers it as // "addable" rather than treating it as already supported. @@ -931,6 +934,38 @@ pub fn get_agent_meta(agent_type: AgentType) -> AcpAgentMeta { node_required: Some("22.0.0"), }, }, + AgentType::Qoder => AcpAgentMeta { + agent_type, + supports_mcp: true, + name: "Qoder", + description: "Alibaba's Qoder coding agent CLI (native ACP via --acp)", + // `qoder --acp` is the CLI's OWN first-party ACP server (not a + // community bridge): verified handshake advertises `loadSession` + // plus the full `sessionCapabilities` set (list/resume/fork/close/ + // delete/additionalDirectories), image + embeddedContext prompts, + // and MCP http+sse — so the resume rung and the codeg-mcp + // companion work with no adapters in between. Auth is the qoder + // account (`qoder login`, or the IDE's qoder-browser flow); there + // is no API-key env to manage. Model, mode (default/acceptEdits/ + // bypassPermissions/plan) and reasoning effort arrive through + // standard `configOptions`, so the composer selectors need no + // per-agent code. Session logs land as + // `$QODER_CONFIG_DIR/projects//.jsonl` + // (default `~/.qoder/...`) in the Claude-Code-style chunk-log + // envelope, which `parsers::qoder` reads for history. The sibling + // `/state.json` keeps its titles ENCRYPTED (AES-GCM + // under the machine key), so the parser derives titles from the + // plaintext transcript instead. `engines.node: ">=20"`. + distribution: AgentDistribution::Npx { + version: "1.1.23", + package: "@qoder-ai/qodercli@1.1.23", + cmd: "qoder", + args: &["--acp"], + env: &[], + // package.json declares `engines.node: ">=20.0.0"`. + node_required: Some("20.0.0"), + }, + }, // Handled by the early return above; kept so the match stays // exhaustive without a catch-all that could swallow a new built-in. AgentType::Custom(_) => unreachable!("custom agents resolve via custom_registry"), @@ -1130,6 +1165,12 @@ mod tests { "deepseek-acp@0.3.0", Some("22.0.0"), ); + assert_npx_version( + AgentType::Qoder, + "1.1.23", + "@qoder-ai/qodercli@1.1.23", + Some("20.0.0"), + ); assert_binary_version(AgentType::OpenCode, "1.18.18", "/releases/download/v1.18.18/"); // Hermes rides the community npm bridge (upstream retired its PyPI // channel at 0.19.0; see the registry entry). The npm package version diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index bff2d5adc..69ea2e9c7 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -7671,6 +7671,18 @@ pub(crate) fn skill_storage_spec(agent_type: AgentType) -> Option/SKILL.md`; it never scans + // flat `.md` files, hence Claude's spec shape rather than Codex's. + // Its roots: `~/.agents/skills` (home scope) and, per workspace, both + // `/skills` and `/.agents/skills`. There is NO + // qoder-owned home skills dir — the home root is the shared + // `.agents` store other agents already use. + AgentType::Qoder => Some(SkillStorageSpec { + kind: SkillStorageKind::SkillDirectoryOnly, + global_dirs: vec![home_dir_or_default().join(".agents").join("skills")], + project_rel_dirs: vec!["skills", ".agents/skills"], + }), // codeg cannot detect where an arbitrary ACP agent loads skills from, // so custom agents are gated on the user's own declaration: that the // agent reads the shared `.agents/skills` store (the cross-agent @@ -8564,6 +8576,13 @@ fn agent_env_keys(agent_type: AgentType) -> (&'static str, &'static str, &'stati "DEEPSEEK_API_KEY", "DEEPSEEK_ACP_MODEL", ), + // Qoder authenticates as the qoder ACCOUNT (`qoder login` / the IDE's + // qoder-browser flow; credentials live in the machine key store, not + // the environment) and has no endpoint override, so none of the three + // slots is real. They stay as inert `QODER_*` placeholders so the + // generic cascade never lands on the OPENAI_* keys; model selection + // flows through the ACP `configOptions` selectors instead. + AgentType::Qoder => ("QODER_BASE_URL", "QODER_API_KEY", "QODER_MODEL"), _ => ("OPENAI_BASE_URL", "OPENAI_API_KEY", "OPENAI_MODEL"), } } @@ -8987,6 +9006,12 @@ fn cascade_update_agent_config( // it has no codeg-managed config file and does not participate in // the model-provider credential cascade. } + AgentType::Qoder => { + // Qoder authenticates as the qoder account (`qoder login` / the + // IDE's qoder-browser flow); the credential lives in qoder's own + // machine-key store, not in any env or file codeg manages, so it + // does not participate in the model-provider credential cascade. + } AgentType::Custom(_) => { // Custom agents are deliberately configuration-free: codeg writes // no config file for them and they are excluded from the diff --git a/src-tauri/src/commands/conversations.rs b/src-tauri/src/commands/conversations.rs index 2e793aef3..778218f01 100644 --- a/src-tauri/src/commands/conversations.rs +++ b/src-tauri/src/commands/conversations.rs @@ -16,6 +16,7 @@ use crate::parsers::cline::ClineParser; use crate::parsers::codebuddy::CodeBuddyParser; use crate::parsers::codex::CodexParser; use crate::parsers::deepseek::DeepSeekParser; +use crate::parsers::qoder::QoderParser; use crate::parsers::gemini::GeminiParser; use crate::parsers::cursor::CursorParser; use crate::parsers::grok::GrokParser; @@ -248,6 +249,7 @@ fn list_conversations_sync( (AgentType::Grok, Box::new(GrokParser::new())), (AgentType::Cursor, Box::new(CursorParser::new())), (AgentType::DeepSeek, Box::new(DeepSeekParser::new())), + (AgentType::Qoder, Box::new(QoderParser::new())), ]; // Registered custom agents read back from codeg's own ACP transcripts, so // their sessions participate in folder grouping and stats like any other. @@ -363,6 +365,7 @@ pub async fn get_conversation( AgentType::Grok => Box::new(GrokParser::new()), AgentType::Cursor => Box::new(CursorParser::new()), AgentType::DeepSeek => Box::new(DeepSeekParser::new()), + AgentType::Qoder => Box::new(QoderParser::new()), // Custom ACP agents have no native store to reverse-engineer; // their history is codeg's own ACP transcript. AgentType::Custom(_) => Box::new(AcpNativeParser::new(agent_type)), @@ -1137,6 +1140,7 @@ pub async fn get_folder_conversation_core( AgentType::Grok => Box::new(GrokParser::new()), AgentType::Cursor => Box::new(CursorParser::new()), AgentType::DeepSeek => Box::new(DeepSeekParser::new()), + AgentType::Qoder => Box::new(QoderParser::new()), AgentType::Custom(_) => Box::new(AcpNativeParser::new(at)), }; match parser.get_conversation(&eid) { diff --git a/src-tauri/src/commands/experts.rs b/src-tauri/src/commands/experts.rs index 4cbaeccb3..02b63644e 100644 --- a/src-tauri/src/commands/experts.rs +++ b/src-tauri/src/commands/experts.rs @@ -791,6 +791,7 @@ fn supported_agents() -> Vec { AgentType::Grok, AgentType::Cursor, AgentType::DeepSeek, + AgentType::Qoder, ]; // Custom agents that declared the shared skills store join the built-in // set — the same `skill_storage_spec` gate every skills surface uses, so diff --git a/src-tauri/src/commands/mcp.rs b/src-tauri/src/commands/mcp.rs index 60eee3a0f..d2db07cd4 100644 --- a/src-tauri/src/commands/mcp.rs +++ b/src-tauri/src/commands/mcp.rs @@ -66,6 +66,9 @@ pub enum McpAppType { /// agent means the MCP page rejects an app the agents page accepts. #[serde(rename = "deepseek")] DeepSeek, + /// Serializes as `qoder` — the same spelling `AgentType::as_wire` returns + /// (snake_case would agree here; pinned by the wire-name test regardless). + Qoder, } #[derive(Debug, Clone, Serialize)] @@ -420,6 +423,7 @@ pub async fn mcp_upsert_local_server( McpAppType::Grok, McpAppType::Cursor, McpAppType::DeepSeek, + McpAppType::Qoder, ]; for app in all_apps { @@ -496,6 +500,7 @@ pub async fn mcp_remove_server( McpAppType::Grok, McpAppType::Cursor, McpAppType::DeepSeek, + McpAppType::Qoder, ], }; @@ -2521,6 +2526,109 @@ fn remove_deepseek_server_at(path: &Path, id: &str) -> Result/settings.json` +// (default `~/.qoder/settings.json`), alongside unrelated keys the CLI owns +// (`securityScan`, `permissions.trustDirectories`, `security.auth`, +// `model.name`, …) and a per-project `projects` map. Writes therefore MUST be +// read-modify-write merges that leave every other key — including formatting +// order — untouched, exactly like the Gemini writer for the same schema. +// +// The CLI reads this file itself at startup, so Qoder sits ON the ACP forward +// skip list in `connection.rs` (with Hermes/Kimi/Grok/Cursor): forwarding the +// same servers again through `session/new.mcpServers` would double-mount +// them. Transports: the verified handshake advertises `mcpCapabilities` +// http+sse, and stdio entries are the gemini-shaped `command`/`args`/`env` — +// so unlike Codex/DeepSeek there is no SSE refusal here. +// --------------------------------------------------------------------------- + +fn qoder_settings_path() -> PathBuf { + crate::parsers::qoder::resolve_qoder_config_dir().join("settings.json") +} + +fn read_qoder_servers() -> Result, AppCommandError> { + read_qoder_servers_at(&qoder_settings_path()) +} + +fn read_qoder_servers_at(path: &Path) -> Result, AppCommandError> { + let root = read_json_file(path)?; + let mut out = BTreeMap::new(); + + let Some(servers) = root.get("mcpServers").and_then(Value::as_object) else { + return Ok(out); + }; + + for (id, spec) in servers { + match canonicalize_spec(spec, "Qoder config") { + Ok(normalized) => { + out.insert(id.to_string(), normalized); + } + Err(err) => { + tracing::warn!("[MCP] skip invalid Qoder MCP entry id={id}: {err}"); + } + } + } + + Ok(out) +} + +fn upsert_qoder_server(id: &str, spec: &Value) -> Result<(), AppCommandError> { + upsert_qoder_server_at(&qoder_settings_path(), id, spec) +} + +fn upsert_qoder_server_at(path: &Path, id: &str, spec: &Value) -> Result<(), AppCommandError> { + let mut root = read_json_file(path)?; + if !root.is_object() { + root = json!({}); + } + + let canonical = canonicalize_spec(spec, "Qoder write")?; + + let obj = root.as_object_mut().ok_or_else(|| { + mcp_configuration_invalid(format!("invalid JSON root in {}", path.display())) + })?; + if !obj.get("mcpServers").map(Value::is_object).unwrap_or(false) { + obj.insert("mcpServers".to_string(), Value::Object(Map::new())); + } + + let map = obj + .get_mut("mcpServers") + .and_then(Value::as_object_mut) + .ok_or_else(|| { + mcp_configuration_invalid(format!("invalid mcpServers in {}", path.display())) + })?; + map.insert(id.to_string(), canonical); + + write_json_file(path, &root) +} + +fn remove_qoder_server(id: &str) -> Result { + remove_qoder_server_at(&qoder_settings_path(), id) +} + +fn remove_qoder_server_at(path: &Path, id: &str) -> Result { + if !path.exists() { + return Ok(false); + } + + let mut root = read_json_file(path)?; + let Some(obj) = root.as_object_mut() else { + return Ok(false); + }; + let Some(servers) = obj.get_mut("mcpServers").and_then(Value::as_object_mut) else { + return Ok(false); + }; + + let removed = servers.remove(id).is_some(); + if removed { + write_json_file(path, &root)?; + } + Ok(removed) +} + fn scan_local_servers() -> Result, AppCommandError> { let mut merged: BTreeMap)> = BTreeMap::new(); @@ -2608,6 +2716,13 @@ fn scan_local_servers() -> Result, AppCommandError> { entry.1.insert(McpAppType::DeepSeek); } + for (id, spec) in read_qoder_servers()? { + let entry = merged + .entry(id) + .or_insert_with(|| (spec.clone(), BTreeSet::new())); + entry.1.insert(McpAppType::Qoder); + } + Ok(merged .into_iter() .map(|(id, (spec, apps))| LocalMcpServer { @@ -2637,6 +2752,7 @@ fn upsert_server_for_app(app: McpAppType, id: &str, spec: &Value) -> Result<(), McpAppType::Grok => upsert_grok_server(id, spec), McpAppType::Cursor => upsert_cursor_server(id, spec), McpAppType::DeepSeek => upsert_deepseek_server(id, spec), + McpAppType::Qoder => upsert_qoder_server(id, spec), } } @@ -2665,6 +2781,10 @@ pub fn read_servers_for_agent_type( // unlike Kimi/Grok/Cursor, DeepSeek must stay OFF the forward skip // list in `connection.rs` or these servers never arrive. AgentType::DeepSeek => read_deepseek_servers(), + // Qoder reads MCP servers from its own settings.json `mcpServers` + // (gemini-schema settings file) at startup — see the Qoder section + // above for why it rides the forward skip list instead of the wire. + AgentType::Qoder => read_qoder_servers(), // Custom agents get MCP purely over the ACP wire (`session/new`'s // `mcpServers`); codeg deliberately knows nothing about their native // config files, so there is no per-agent store to read back here. @@ -3586,6 +3706,7 @@ fn remove_server_for_app(app: McpAppType, id: &str) -> Result remove_grok_server(id), McpAppType::Cursor => remove_cursor_server(id), McpAppType::DeepSeek => remove_deepseek_server(id), + McpAppType::Qoder => remove_qoder_server(id), } } @@ -5607,6 +5728,125 @@ mod tests { ); } + #[test] + fn qoder_settings_json_round_trips_and_preserves_foreign_keys() { + // `~/.qoder/settings.json` is QODER'S file, not codeg's: the CLI owns + // unrelated keys beside `mcpServers` (`securityScan`, `security.auth`, + // `model.name`, the `projects` map, …). Every write must be a + // read-modify-write merge that leaves those keys intact — the property + // that separates this writer from DeepSeek's own-store writer above. + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("settings.json"); + + assert!(read_qoder_servers_at(&path) + .expect("read missing") + .is_empty()); + assert!(!remove_qoder_server_at(&path, "ctx7").expect("remove missing")); + + // A realistic settings.json: foreign keys plus one valid server and + // one entry too malformed to canonicalize (exercises the skip path + // on read — codeg must skip it, never rewrite the file to "fix" it). + std::fs::write( + &path, + serde_json::to_string_pretty(&json!({ + "securityScan": true, + "security": { "auth": { "provider": "qoder-account" } }, + "model": { "name": "qmodel_38max" }, + "permissions": { "trustDirectories": true }, + "mcpServers": { + "existing": { "command": "uvx", "args": ["mcp-existing"] }, + "broken": {} + } + })) + .expect("seed json"), + ) + .expect("seed settings.json"); + + upsert_qoder_server_at( + &path, + "ctx7", + &json!({ + "command": "npx", + "args": ["-y", "ctx7-mcp"], + "env": { "TOKEN": "t" }, + }), + ) + .expect("upsert"); + // Qoder's verified handshake advertises http+sse — a url-only entry + // canonicalizes to http, and neither remote transport is refused + // (unlike Codex/DeepSeek in `app_can_host_spec`). + upsert_qoder_server_at( + &path, + "remote", + &json!({ "url": "https://mcp.example.com/mcp" }), + ) + .expect("upsert remote"); + assert!(app_can_host_spec( + McpAppType::Qoder, + &json!({ "type": "sse", "url": "https://mcp.example.com/sse" }) + )); + + let servers = read_qoder_servers_at(&path).expect("read back"); + assert_eq!(servers.len(), 3, "valid entries only — broken is skipped"); + assert_eq!( + servers + .get("ctx7") + .and_then(|s| s.get("command")) + .and_then(Value::as_str), + Some("npx") + ); + assert_eq!( + servers + .get("remote") + .and_then(|s| s.get("type")) + .and_then(Value::as_str), + Some("http") + ); + assert_eq!( + servers + .get("existing") + .and_then(|s| s.get("command")) + .and_then(Value::as_str), + Some("uvx") + ); + + // The merge promise: after both writes, every foreign key and the + // skipped malformed entry are still on disk. + let root: Value = + serde_json::from_str(&std::fs::read_to_string(&path).expect("read file")) + .expect("parse json"); + assert_eq!(root.pointer("/securityScan"), Some(&json!(true))); + assert_eq!( + root.pointer("/model/name"), + Some(&json!("qmodel_38max")) + ); + assert_eq!( + root.pointer("/permissions/trustDirectories"), + Some(&json!(true)) + ); + assert_eq!( + root.pointer("/mcpServers/existing/command"), + Some(&json!("uvx")) + ); + assert!( + root.pointer("/mcpServers/broken").is_some(), + "the malformed entry stays on disk — skipping it must not rewrite it away" + ); + + // Remove takes only the targeted server; a missing id returns false + // without rewriting, and the foreign keys survive the final write too. + assert!(remove_qoder_server_at(&path, "ctx7").expect("remove")); + assert!(!remove_qoder_server_at(&path, "nope").expect("remove missing id")); + let after = read_qoder_servers_at(&path).expect("read after remove"); + assert_eq!(after.len(), 2); + assert!(after.contains_key("remote")); + assert!(after.contains_key("existing")); + let root: Value = + serde_json::from_str(&std::fs::read_to_string(&path).expect("re-read")) + .expect("re-parse"); + assert_eq!(root.pointer("/model/name"), Some(&json!("qmodel_38max"))); + } + #[test] fn mcp_app_type_wire_names_match_the_agent_type_they_name() { use crate::models::agent::AgentType; @@ -5631,6 +5871,7 @@ mod tests { (McpAppType::Grok, AgentType::Grok), (McpAppType::Cursor, AgentType::Cursor), (McpAppType::DeepSeek, AgentType::DeepSeek), + (McpAppType::Qoder, AgentType::Qoder), ] { let wire = serde_json::to_value(app).expect("serialize app type"); assert_eq!( diff --git a/src-tauri/src/commands/office_tools.rs b/src-tauri/src/commands/office_tools.rs index c20582691..801b96406 100644 --- a/src-tauri/src/commands/office_tools.rs +++ b/src-tauri/src/commands/office_tools.rs @@ -1061,6 +1061,7 @@ fn supported_agents() -> Vec { AgentType::Grok, AgentType::Cursor, AgentType::DeepSeek, + AgentType::Qoder, ]; // Custom agents that declared the shared skills store join the built-in // set — the same `skill_storage_spec` gate every skills surface uses, so diff --git a/src-tauri/src/db/service/agent_setting_service.rs b/src-tauri/src/db/service/agent_setting_service.rs index 9403b8ee7..b32c4a4b3 100644 --- a/src-tauri/src/db/service/agent_setting_service.rs +++ b/src-tauri/src/db/service/agent_setting_service.rs @@ -41,6 +41,7 @@ fn default_enabled(agent_type: AgentType) -> bool { | AgentType::Grok | AgentType::Cursor | AgentType::DeepSeek + | AgentType::Qoder // A user who just registered a custom agent wants to use it. | AgentType::Custom(_) ) diff --git a/src-tauri/src/db/service/import_service.rs b/src-tauri/src/db/service/import_service.rs index 42d928183..c4cd7945f 100644 --- a/src-tauri/src/db/service/import_service.rs +++ b/src-tauri/src/db/service/import_service.rs @@ -13,6 +13,7 @@ use crate::parsers::codebuddy::CodeBuddyParser; use crate::parsers::codex::CodexParser; use crate::parsers::cursor::CursorParser; use crate::parsers::deepseek::DeepSeekParser; +use crate::parsers::qoder::QoderParser; use crate::parsers::gemini::GeminiParser; use crate::parsers::grok::GrokParser; use crate::parsers::hermes::HermesParser; @@ -23,7 +24,7 @@ use crate::parsers::opencode::OpenCodeParser; use crate::parsers::{path_eq_for_matching, AgentParser}; /// Every locally-parsable agent, in the canonical parser order. -const ALL_PARSER_AGENTS: [AgentType; 13] = [ +const ALL_PARSER_AGENTS: [AgentType; 14] = [ AgentType::ClaudeCode, AgentType::Codex, AgentType::OpenCode, @@ -37,6 +38,7 @@ const ALL_PARSER_AGENTS: [AgentType; 13] = [ AgentType::Grok, AgentType::Cursor, AgentType::DeepSeek, + AgentType::Qoder, ]; fn build_parser(agent_type: AgentType) -> Box { @@ -54,6 +56,7 @@ fn build_parser(agent_type: AgentType) -> Box { AgentType::Grok => Box::new(GrokParser::new()), AgentType::Cursor => Box::new(CursorParser::new()), AgentType::DeepSeek => Box::new(DeepSeekParser::new()), + AgentType::Qoder => Box::new(QoderParser::new()), // Custom agents' history lives in codeg's own ACP transcript. AgentType::Custom(_) => Box::new(crate::parsers::acp_native::AcpNativeParser::new( agent_type, diff --git a/src-tauri/src/models/agent.rs b/src-tauri/src/models/agent.rs index ad6f1968c..705d7524b 100644 --- a/src-tauri/src/models/agent.rs +++ b/src-tauri/src/models/agent.rs @@ -8,7 +8,7 @@ pub const CUSTOM_AGENT_WIRE_PREFIX: &str = "custom:"; /// Which agent backs a conversation. /// -/// The thirteen named variants are compile-time built-ins with hand-written +/// The fourteen named variants are compile-time built-ins with hand-written /// launch metadata (`acp::registry`) and a dedicated transcript parser /// (`parsers::*`). [`AgentType::Custom`] is the open end: a user-registered /// ACP agent whose launch metadata lives in the database @@ -34,12 +34,13 @@ pub enum AgentType { Grok, Cursor, DeepSeek, + Qoder, /// A user-registered ACP agent, identified by its ACP-registry id /// (interned). Ordered last so built-ins keep their relative order. Custom(&'static str), } -/// The thirteen compile-time agents, in declaration order. Does NOT include +/// The fourteen compile-time agents, in declaration order. Does NOT include /// custom agents — use [`crate::acp::registry::all_acp_agents`] for the live /// set that includes them. pub const BUILTIN_AGENT_TYPES: &[AgentType] = &[ @@ -56,6 +57,7 @@ pub const BUILTIN_AGENT_TYPES: &[AgentType] = &[ AgentType::Grok, AgentType::Cursor, AgentType::DeepSeek, + AgentType::Qoder, ]; impl AgentType { @@ -101,6 +103,7 @@ impl AgentType { AgentType::Grok => Cow::Borrowed("grok"), AgentType::Cursor => Cow::Borrowed("cursor"), AgentType::DeepSeek => Cow::Borrowed("deepseek"), + AgentType::Qoder => Cow::Borrowed("qoder"), AgentType::Custom(id) => Cow::Owned(format!("{CUSTOM_AGENT_WIRE_PREFIX}{id}")), } } @@ -122,6 +125,7 @@ impl AgentType { "grok" => Some(AgentType::Grok), "cursor" => Some(AgentType::Cursor), "deepseek" => Some(AgentType::DeepSeek), + "qoder" => Some(AgentType::Qoder), other => other .strip_prefix(CUSTOM_AGENT_WIRE_PREFIX) .and_then(AgentType::custom), @@ -162,6 +166,7 @@ pub fn is_valid_custom_agent_id(id: &str) -> bool { | "grok" | "cursor" | "deepseek" + | "qoder" ) } @@ -195,6 +200,7 @@ impl fmt::Display for AgentType { AgentType::Grok => write!(f, "Grok"), AgentType::Cursor => write!(f, "Cursor"), AgentType::DeepSeek => write!(f, "DeepSeek Harness"), + AgentType::Qoder => write!(f, "Qoder"), // Prefer the registered display name; fall back to the raw id when // the registry has not been hydrated (or the agent was deleted // while conversations still reference it). @@ -228,6 +234,7 @@ mod tests { (AgentType::Grok, "grok"), (AgentType::Cursor, "cursor"), (AgentType::DeepSeek, "deepseek"), + (AgentType::Qoder, "qoder"), ]; for (agent, wire) in expected { assert_eq!(agent.as_wire(), wire); @@ -296,6 +303,7 @@ mod tests { "codex", "claude_code", "deepseek", + "qoder", ] { assert!( !is_valid_custom_agent_id(bad), @@ -314,9 +322,10 @@ mod tests { #[test] fn ordering_places_custom_after_builtins() { - assert!(AgentType::DeepSeek < AgentType::custom("goose").unwrap()); + assert!(AgentType::Qoder < AgentType::custom("goose").unwrap()); assert!(AgentType::ClaudeCode < AgentType::Cursor); assert!(AgentType::Cursor < AgentType::DeepSeek); + assert!(AgentType::DeepSeek < AgentType::Qoder); // Custom agents order lexicographically among themselves. assert!(AgentType::custom("aaa").unwrap() < AgentType::custom("bbb").unwrap()); } diff --git a/src-tauri/src/parsers/mod.rs b/src-tauri/src/parsers/mod.rs index 0f084f175..fcbbf4518 100644 --- a/src-tauri/src/parsers/mod.rs +++ b/src-tauri/src/parsers/mod.rs @@ -13,6 +13,7 @@ pub mod kimi_code; pub mod openclaw; pub mod opencode; pub mod pi; +pub mod qoder; mod summary_cache; use std::collections::{HashMap, HashSet}; @@ -166,6 +167,17 @@ pub fn external_transcript_sources() -> Vec { is_file: false, include_top: None, }, + ExternalSource { + // Qoder keeps one JSONL per session under + // `~/.qoder/projects//.jsonl` (relocatable + // via `QODER_CONFIG_DIR`). The resolver already points at the + // `projects/` subtree, so the sibling `settings.json` / + // `security/` / `cache/` under `~/.qoder` are never archived. + agent: "qoder", + root: qoder::resolve_qoder_config_dir().join("projects"), + is_file: false, + include_top: None, + }, ]; if let Some(home) = dirs::home_dir() { sources.push(ExternalSource { diff --git a/src-tauri/src/parsers/qoder.rs b/src-tauri/src/parsers/qoder.rs new file mode 100644 index 000000000..baa94537b --- /dev/null +++ b/src-tauri/src/parsers/qoder.rs @@ -0,0 +1,840 @@ +use std::ffi::OsString; +use std::fs; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; + +use chrono::{DateTime, Utc}; +use serde_json::Value; +use walkdir::WalkDir; + +use crate::models::{ + AgentType, ContentBlock, ConversationDetail, ConversationSummary, MessageRole, + TurnUsage, UnifiedMessage, +}; +use crate::parsers::{ + backfill_turn_durations, compute_session_stats, folder_name_from_path, + infer_context_window_max_tokens, latest_turn_total_usage_tokens, + merge_context_window_stats, relocate_orphaned_tool_results, resolve_patch_line_numbers, + structurize_read_tool_output, title_from_user_text, AgentParser, ParseError, +}; + +/// Resolve Qoder's config dir, honoring `QODER_CONFIG_DIR`, else `~/.qoder` +/// (mirrors `resolve_claude_config_dir`; `--config-dir` is the CLI flag form +/// of the same override). +pub(crate) fn resolve_qoder_config_dir() -> PathBuf { + resolve_qoder_config_dir_from(std::env::var_os("QODER_CONFIG_DIR"), dirs::home_dir()) +} + +fn resolve_qoder_config_dir_from( + qoder_config_dir_env: Option, + home_dir: Option, +) -> PathBuf { + qoder_config_dir_env + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| home_dir.unwrap_or_default().join(".qoder")) +} + +/// Qoder (Alibaba) stores one JSONL transcript per session under +/// `~/.qoder/projects//.jsonl`, next to a +/// `/` directory whose `state.json` carries session metadata. +/// The transcript uses the Claude-Code-style chunk-log envelope +/// (`type: "user" | "assistant"`, `uuid`/`parentUuid` chain, +/// `message.content` block arrays) plus qoder-specific metadata records +/// (`workspace-directories`, `runtime-config`, `active-leaf`, `last-prompt`) +/// that are interleaved with — and duplicated at the tail of — the content +/// stream. +/// +/// The sibling `state.json` holds the authoritative session titles, but +/// ENCRYPTED (an `items` map of AES-GCM `{n: nonce, p: ciphertext, t: tag}` +/// blobs under the machine key from `~/.qoder/security/`), so the parser +/// derives titles from the plaintext transcript (first human prompt) instead. +pub struct QoderParser { + base_dir: PathBuf, +} + +impl QoderParser { + pub fn new() -> Self { + Self { + base_dir: resolve_qoder_config_dir().join("projects"), + } + } + + /// Construct a parser pointed at an explicit `projects` directory (test + /// fixtures). + #[cfg(any(test, feature = "test-utils"))] + pub fn with_base_dir(base_dir: PathBuf) -> Self { + Self { base_dir } + } + + /// Locate a session's transcript file by id: `//.jsonl`. + /// The encoded-cwd segment is unknowable from the id alone, so this scans + /// the projects tree for a matching file stem — the same shape Claude's + /// per-project `read_dir` lookup has. + fn transcript_path_for(&self, conversation_id: &str) -> Option { + let expected = format!("{conversation_id}.jsonl"); + WalkDir::new(&self.base_dir) + .max_depth(2) + .into_iter() + .filter_map(|e| e.ok()) + .find(|entry| entry.file_name().to_str() == Some(expected.as_str())) + .map(|entry| entry.into_path()) + } + + /// The per-line summary scan. Content records (`user`/`assistant`) drive + /// timestamps and counts; metadata records contribute what only they carry + /// (`runtime-config.model`). `sidechain` entries (qoder runs sub-agents + /// inline in the same file with `isSidechain: true`) are excluded — they + /// are a sub-agent's internal execution, not this conversation's turns. + fn parse_summary(&self, path: &Path) -> Option { + let reader = BufReader::new(fs::File::open(path).ok()?); + + let mut first_ts: Option> = None; + let mut last_ts: Option> = None; + let mut first_user_text: Option = None; + let mut model: Option = None; + let mut cwd: Option = None; + let mut git_branch: Option = None; + let mut session_id: Option = None; + let mut message_count: u32 = 0; + + for line in reader.lines() { + let Ok(line) = line else { continue }; + if line.trim().is_empty() { + continue; + } + let Ok(value) = serde_json::from_str::(&line) else { + continue; + }; + + let record_type = value.get("type").and_then(|t| t.as_str()).unwrap_or(""); + let is_sidechain = value + .get("isSidechain") + .and_then(|s| s.as_bool()) + .unwrap_or(false); + + if session_id.is_none() { + session_id = value + .get("sessionId") + .and_then(|s| s.as_str()) + .map(String::from); + } + if cwd.is_none() { + cwd = value + .get("cwd") + .and_then(|c| c.as_str()) + .map(String::from); + } + if git_branch.is_none() { + git_branch = value + .get("gitBranch") + .and_then(|g| g.as_str()) + .map(String::from); + } + // `runtime-config` records the launch-time model; an assistant + // record's `message.model` is the live per-message truth, so only + // fall back to this when no assistant record has spoken yet. + if model.is_none() && record_type == "runtime-config" && !is_sidechain { + model = value + .get("model") + .and_then(|m| m.as_str()) + .map(String::from); + } + + if !is_content_record(record_type) || is_sidechain { + continue; + } + if let Some(ts) = record_timestamp(&value) { + first_ts.get_or_insert(ts); + last_ts = Some(ts); + } + + match record_type { + "user" => { + message_count += 1; + if first_user_text.is_none() { + if let Some(text) = human_user_text(&value) { + if !text.trim().is_empty() { + first_user_text = + Some(title_from_user_text(text.trim())); + } + } + } + } + "assistant" => { + message_count += 1; + if model.is_none() { + model = value + .get("message") + .and_then(|m| m.get("model")) + .and_then(|m| m.as_str()) + .map(String::from); + } + } + _ => {} + } + } + + let started_at = first_ts?; + let id = session_id.unwrap_or_else(|| { + path.file_stem() + .unwrap_or_default() + .to_string_lossy() + .into_owned() + }); + let folder_name = cwd.as_deref().map(folder_name_from_path); + + Some(ConversationSummary { + id, + agent_type: AgentType::Qoder, + folder_path: cwd, + folder_name, + title: first_user_text, + started_at, + ended_at: last_ts, + message_count, + model, + git_branch, + parent_id: None, + parent_tool_use_id: None, + delegation_call_id: None, + }) + } + + fn parse_detail( + &self, + path: &Path, + conversation_id: &str, + ) -> Result { + // Read the file fully up front so `transcript_watermark` is EXACTLY the + // byte length this parse consumed — see the same contract in + // `parsers::claude`. An over-claiming watermark (e.g. from a stat + // around the read) would make the frontend retire background-overlay + // turns whose content this detail does not include. + let bytes = fs::read(path)?; + let transcript_watermark = bytes.len() as u64; + + let mut messages: Vec = Vec::new(); + // `message.id` of the assistant message currently being accumulated + // (see the assistant arm); `None` while the last record was not an + // assistant fragment. + let mut pending_assistant_chat_id: Option = None; + let mut first_ts: Option> = None; + let mut last_ts: Option> = None; + let mut first_user_text: Option = None; + let mut model: Option = None; + let mut cwd: Option = None; + let mut git_branch: Option = None; + let mut message_count: u32 = 0; + + for chunk in bytes.split(|b| *b == b'\n') { + let Ok(line) = std::str::from_utf8(chunk) else { + continue; + }; + if line.trim().is_empty() { + continue; + } + let Ok(value) = serde_json::from_str::(line) else { + continue; + }; + + let record_type = value.get("type").and_then(|t| t.as_str()).unwrap_or(""); + let is_sidechain = value + .get("isSidechain") + .and_then(|s| s.as_bool()) + .unwrap_or(false); + + if cwd.is_none() { + cwd = value + .get("cwd") + .and_then(|c| c.as_str()) + .map(String::from); + } + if git_branch.is_none() { + git_branch = value + .get("gitBranch") + .and_then(|g| g.as_str()) + .map(String::from); + } + if model.is_none() && record_type == "runtime-config" && !is_sidechain { + model = value + .get("model") + .and_then(|m| m.as_str()) + .map(String::from); + } + + if !is_content_record(record_type) || is_sidechain { + continue; + } + let Some(ts) = record_timestamp(&value).or(last_ts) else { + continue; + }; + first_ts.get_or_insert(ts); + last_ts = Some(ts); + + let uuid = value + .get("uuid") + .and_then(|u| u.as_str()) + .map(String::from); + + match record_type { + "user" => { + message_count += 1; + if let Some(text) = human_user_text(&value) { + if first_user_text.is_none() && !text.trim().is_empty() { + first_user_text = + Some(title_from_user_text(text.trim())); + } + if !text.trim().is_empty() { + messages.push(UnifiedMessage { + id: uuid.unwrap_or_else(|| format!("q-user-{}", messages.len())), + role: MessageRole::User, + content: vec![ContentBlock::Text { text }], + timestamp: ts, + usage: None, + duration_ms: None, + model: None, + completed_at: Some(ts), + }); + } + } else { + // A user record whose content is a block array is a + // tool-result delivery (Claude envelope semantics). + // Empty content lists nothing; a synthetic user turn + // with zero blocks would only pollute the transcript. + let blocks = tool_result_blocks(&value); + if !blocks.is_empty() { + messages.push(UnifiedMessage { + id: uuid + .unwrap_or_else(|| format!("q-toolresult-{}", messages.len())), + role: MessageRole::User, + content: blocks, + timestamp: ts, + usage: None, + duration_ms: None, + model: None, + completed_at: Some(ts), + }); + } + } + } + "assistant" => { + message_count += 1; + let blocks = assistant_blocks(&value); + if blocks.is_empty() { + continue; + } + let entry_model = value + .get("message") + .and_then(|m| m.get("model")) + .and_then(|m| m.as_str()) + .map(String::from); + model.get_or_insert_with(|| entry_model.clone().unwrap_or_default()); + if model.as_deref() == Some("") { + model = entry_model.clone(); + } + // One API response streams as SEVERAL assistant records + // (thinking, tool_use, final text) sharing one + // `message.id`; they must merge into a single message or + // every fragment becomes its own turn. A record whose id + // differs from the pending message's (or carries none) + // starts a new one — the same accumulation shape as the + // Claude parser. + let message_id = value + .get("message") + .and_then(|m| m.get("id")) + .and_then(|m| m.as_str()) + .map(String::from); + let pending_same_id = match (&messages.last(), &message_id) { + ( + Some(UnifiedMessage { + role: MessageRole::Assistant, + .. + }), + Some(id), + ) => pending_assistant_chat_id.as_deref() == Some(id.as_str()), + _ => false, + }; + if pending_same_id { + let last = messages.last_mut().expect("checked non-empty"); + last.content.extend(blocks); + last.completed_at = Some(ts); + // Later fragments carry the settled usage (the + // end-turn record) and the fuller model name. + if let Some(usage) = usage_from_record(&value) { + last.usage = Some(usage); + } + if let Some(m) = entry_model { + last.model = Some(m); + } + } else { + messages.push(UnifiedMessage { + id: uuid + .unwrap_or_else(|| format!("q-assistant-{}", messages.len())), + role: MessageRole::Assistant, + content: blocks, + timestamp: ts, + usage: usage_from_record(&value), + duration_ms: None, + model: entry_model, + completed_at: Some(ts), + }); + } + pending_assistant_chat_id = message_id; + } + _ => {} + } + } + + let mut turns = crate::parsers::claude::group_into_turns(messages); + relocate_orphaned_tool_results(&mut turns); + structurize_read_tool_output(&mut turns); + resolve_patch_line_numbers(&mut turns, cwd.as_deref()); + backfill_turn_durations(&mut turns, &[]); + + let used_tokens = latest_turn_total_usage_tokens(&turns); + let max_tokens = infer_context_window_max_tokens(model.as_deref()); + let session_stats = + merge_context_window_stats(compute_session_stats(&turns), used_tokens, max_tokens); + + let folder_name = cwd.as_deref().map(folder_name_from_path); + let summary = ConversationSummary { + id: conversation_id.to_string(), + agent_type: AgentType::Qoder, + folder_path: cwd, + folder_name, + // Same precedence as `parse_summary` — the two paths MUST agree, or + // the auto-title backfill would oscillate between them. + title: first_user_text, + started_at: first_ts.unwrap_or_else(Utc::now), + ended_at: last_ts, + message_count, + model, + git_branch, + parent_id: None, + parent_tool_use_id: None, + delegation_call_id: None, + }; + + Ok(ConversationDetail { + summary, + turns, + session_stats, + transcript_watermark: Some(transcript_watermark), + }) + } +} + +impl Default for QoderParser { + fn default() -> Self { + Self::new() + } +} + +/// Content records — the two record types that carry conversation turns. The +/// metadata records (`workspace-directories`, `runtime-config`, `active-leaf`, +/// `last-prompt`) interleave with these and repeat at the file tail; none of +/// them advance timestamps or counts. Their integer-epoch `timestamp` fields +/// (content records use ISO strings) are the tell that they are not content. +fn is_content_record(record_type: &str) -> bool { + matches!(record_type, "user" | "assistant") +} + +/// ISO-8601 `timestamp` string on a content record. +fn record_timestamp(value: &Value) -> Option> { + value + .get("timestamp") + .and_then(|t| t.as_str()) + .and_then(|t| DateTime::parse_from_rfc3339(t).ok()) + .map(|t| t.with_timezone(&Utc)) +} + +/// The human prompt behind a user record, when it is one. A user record whose +/// `message.content` is a block array is a tool-result delivery (or a +/// sidechain-visible synthetic prompt) — returning `None` routes it to the +/// tool-result path instead of rendering internals as a user bubble. +fn human_user_text(value: &Value) -> Option { + // Only genuine human input becomes a user bubble: qoder marks the real + // prompts with `origin.kind == "human"` (tool results and synthetic + // continuation records do not carry it). + let is_human = value + .get("origin") + .and_then(|o| o.get("kind")) + .and_then(|k| k.as_str()) + .map(|k| k == "human") + .unwrap_or(false); + if !is_human { + return None; + } + value + .get("message") + .and_then(|m| m.get("content")) + .and_then(|c| c.as_str()) + .map(String::from) +} + +/// `tool_result` blocks inside a user record's content array. +fn tool_result_blocks(value: &Value) -> Vec { + let mut blocks = Vec::new(); + let Some(arr) = value + .get("message") + .and_then(|m| m.get("content")) + .and_then(|c| c.as_array()) + else { + return blocks; + }; + for item in arr { + let block_type = item.get("type").and_then(|t| t.as_str()).unwrap_or(""); + if block_type != "tool_result" { + continue; + } + let tool_use_id = item + .get("tool_use_id") + .and_then(|n| n.as_str()) + .map(String::from); + let output = tool_result_text(item); + let is_error = item + .get("is_error") + .and_then(|e| e.as_bool()) + .unwrap_or(false); + blocks.push(ContentBlock::ToolResult { + tool_use_id, + output_preview: output, + is_error, + agent_stats: None, + images: Vec::new(), + }); + } + blocks +} + +/// A tool result's `content` is either a plain string or an array of +/// `{type: "text", text}` blocks (Claude envelope allows both). +fn tool_result_text(item: &Value) -> Option { + match item.get("content") { + Some(Value::String(s)) => Some(s.clone()), + Some(Value::Array(arr)) => { + let texts: Vec<&str> = arr + .iter() + .filter_map(|b| b.get("text").and_then(|t| t.as_str())) + .collect(); + (!texts.is_empty()).then(|| texts.join("\n")) + } + _ => None, + } +} + +/// Content blocks of an assistant record: `thinking`, `text`, `tool_use`. +fn assistant_blocks(value: &Value) -> Vec { + let mut blocks = Vec::new(); + let Some(arr) = value + .get("message") + .and_then(|m| m.get("content")) + .and_then(|c| c.as_array()) + else { + return blocks; + }; + for item in arr { + let block_type = item.get("type").and_then(|t| t.as_str()).unwrap_or(""); + match block_type { + "text" => { + if let Some(text) = item.get("text").and_then(|t| t.as_str()) { + blocks.push(ContentBlock::Text { + text: text.to_string(), + }); + } + } + "thinking" => { + if let Some(text) = item.get("thinking").and_then(|t| t.as_str()) { + blocks.push(ContentBlock::Thinking { + text: text.to_string(), + }); + } + } + "tool_use" => { + let tool_use_id = item + .get("id") + .and_then(|n| n.as_str()) + .map(String::from); + let tool_name = item + .get("name") + .and_then(|n| n.as_str()) + .unwrap_or("unknown") + .to_string(); + let input_preview = item.get("input").map(|i| i.to_string()); + blocks.push(ContentBlock::ToolUse { + tool_use_id, + tool_name, + input_preview, + status: None, + meta: None, + }); + } + _ => {} + } + } + blocks +} + +/// Token usage off an assistant record's `message.usage`. Qoder meters its +/// subscription in `credits` (present in the same object) — codeg's usage +/// model is token-shaped, so credits are left in the transcript and only the +/// token counters map. +fn usage_from_record(value: &Value) -> Option { + let usage = value.get("message")?.get("usage")?; + Some(TurnUsage { + input_tokens: usage + .get("input_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0), + output_tokens: usage + .get("output_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0), + cache_creation_input_tokens: usage + .get("cache_creation_input_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0), + cache_read_input_tokens: usage + .get("cache_read_input_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0), + }) +} + +impl AgentParser for QoderParser { + fn list_conversations(&self) -> Result, ParseError> { + let mut conversations = Vec::new(); + if !self.base_dir.exists() { + return Ok(conversations); + } + + for entry in WalkDir::new(&self.base_dir) + .max_depth(2) + .into_iter() + .filter_map(|e| e.ok()) + { + let path = entry.path(); + if !entry.file_type().is_file() + || path.extension().and_then(|e| e.to_str()) != Some("jsonl") + { + continue; + } + if let Ok(Some(summary)) = super::summary_cache::get_or_parse( + AgentType::Qoder, + path, + || Ok(self.parse_summary(path)), + ) { + conversations.push(summary); + } + } + + conversations.sort_by_key(|c| std::cmp::Reverse(c.started_at)); + Ok(conversations) + } + + fn get_conversation(&self, conversation_id: &str) -> Result { + let path = self + .transcript_path_for(conversation_id) + .ok_or_else(|| ParseError::ConversationNotFound(conversation_id.to_string()))?; + self.parse_detail(&path, conversation_id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parser_in(tmp: &std::path::Path) -> QoderParser { + QoderParser::with_base_dir(tmp.join("projects")) + } + + fn write_session(tmp: &std::path::Path, id: &str, lines: &[&str]) -> PathBuf { + let dir = tmp.join("projects").join("-private-tmp-probe"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(format!("{id}.jsonl")); + std::fs::write(&path, lines.join("\n") + "\n").unwrap(); + path + } + + // Lines captured verbatim from a real qoder 1.1.23 session (paths and ids + // trimmed to the essentials the parser reads). + const RUNTIME_CONFIG: &str = r#"{"type":"runtime-config","sessionId":"s1","model":"qmodel_38max","reasoningEffort":null,"contextWindow":null,"generation":null,"timestamp":1786895127277}"#; + const USER_LINE: &str = r#"{"type":"user","uuid":"u1","timestamp":"2026-08-16T15:45:27.384Z","message":{"role":"user","content":"read NOTES.md and reply"},"permissionMode":"default","origin":{"kind":"human"},"promptId":"s1","humanInput":{"text":"read NOTES.md and reply","mode":"prompt"},"parentUuid":null,"isSidechain":false,"cwd":"/private/tmp/probe","sessionId":"s1","userType":"external","entrypoint":"cli","version":"1.1.23","gitBranch":"main"}"#; + const THINKING_LINE: &str = r#"{"type":"assistant","uuid":"a1","timestamp":"2026-08-16T15:45:33.089Z","message":{"id":"chatcmpl-1","type":"message","role":"assistant","model":"qmodel_38max","stop_reason":null,"content":[{"type":"thinking","thinking":"Need to read the file.","signature":""}]},"parentUuid":"u1","isSidechain":false,"cwd":"/private/tmp/probe","sessionId":"s1","userType":"external","entrypoint":"cli","version":"1.1.23","gitBranch":"main"}"#; + const TOOL_USE_LINE: &str = r#"{"type":"assistant","uuid":"a2","timestamp":"2026-08-16T15:45:33.100Z","message":{"id":"chatcmpl-1","type":"message","role":"assistant","model":"qmodel_38max","stop_reason":"tool_use","content":[{"type":"tool_use","id":"call_1","name":"Read","input":{"file_path":"/private/tmp/probe/NOTES.md"}}]},"parentUuid":"a1","isSidechain":false,"cwd":"/private/tmp/probe","sessionId":"s1","userType":"external","entrypoint":"cli","version":"1.1.23","gitBranch":"main"}"#; + const TOOL_RESULT_LINE: &str = r#"{"type":"user","uuid":"u2","timestamp":"2026-08-16T15:45:33.200Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":"1\tthe secret number is 42\n2\t"}]},"sourceToolAssistantUUID":"a2","promptId":"s1","toolUseResult":{"type":"text","file":{"filePath":"NOTES.md","content":"the secret number is 42\n","numLines":2,"startLine":1,"totalLines":2}},"parentUuid":"a2","isSidechain":false,"cwd":"/private/tmp/probe","sessionId":"s1","userType":"external","entrypoint":"cli","version":"1.1.23","gitBranch":"main"}"#; + const ANSWER_LINE: &str = r#"{"type":"assistant","uuid":"a3","timestamp":"2026-08-16T15:45:34.000Z","message":{"id":"chatcmpl-1","type":"message","role":"assistant","model":"qmodel_38max","stop_reason":"end_turn","content":[{"type":"text","text":"42","citations":null}],"usage":{"input_tokens":10,"cache_creation_input_tokens":0,"cache_read_input_tokens":5,"output_tokens":2,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0}}},"parentUuid":"u2","isSidechain":false,"cwd":"/private/tmp/probe","sessionId":"s1","userType":"external","entrypoint":"cli","version":"1.1.23","gitBranch":"main"}"#; + const ACTIVE_LEAF_LINE: &str = r#"{"type":"active-leaf","sessionId":"s1","leafUuid":"a3","explicit":false,"timestamp":1786895133089}"#; + const LAST_PROMPT_LINE: &str = r#"{"type":"last-prompt","sessionId":"s1","lastPrompt":"read NOTES.md and reply"}"#; + const SIDECHAIN_LINE: &str = r#"{"type":"assistant","uuid":"sc1","timestamp":"2026-08-16T15:45:35.000Z","message":{"role":"assistant","model":"qmodel_38max","content":[{"type":"text","text":"sub-agent internal output"}]},"parentUuid":null,"isSidechain":true,"cwd":"/private/tmp/probe","sessionId":"s1","userType":"external","entrypoint":"cli","version":"1.1.23","gitBranch":"main"}"#; + + #[test] + fn summary_reads_real_layout() { + let tmp = tempfile::tempdir().unwrap(); + write_session( + tmp.path(), + "s1", + &[ + RUNTIME_CONFIG, + USER_LINE, + THINKING_LINE, + TOOL_USE_LINE, + TOOL_RESULT_LINE, + ANSWER_LINE, + ACTIVE_LEAF_LINE, + LAST_PROMPT_LINE, + // The tail repeats metadata records verbatim; none may move + // the timestamps or counts. + RUNTIME_CONFIG, + ACTIVE_LEAF_LINE, + ], + ); + + let summaries = parser_in(tmp.path()).list_conversations().unwrap(); + assert_eq!(summaries.len(), 1); + let s = &summaries[0]; + assert_eq!(s.id, "s1"); + assert_eq!(s.agent_type, AgentType::Qoder); + assert_eq!(s.title.as_deref(), Some("read NOTES.md and reply")); + assert_eq!(s.folder_path.as_deref(), Some("/private/tmp/probe")); + assert_eq!(s.folder_name.as_deref(), Some("probe")); + assert_eq!(s.model.as_deref(), Some("qmodel_38max")); + assert_eq!(s.git_branch.as_deref(), Some("main")); + // 2 user + 3 assistant content records; metadata records don't count. + assert_eq!(s.message_count, 5); + assert_eq!( + s.started_at.to_rfc3339(), + "2026-08-16T15:45:27.384+00:00" + ); + assert_eq!(s.ended_at.as_ref().map(|t| t.to_rfc3339()), Some("2026-08-16T15:45:34+00:00".to_string())); + } + + #[test] + fn detail_builds_turns_with_tool_pairing() { + let tmp = tempfile::tempdir().unwrap(); + let path = write_session( + tmp.path(), + "s1", + &[ + RUNTIME_CONFIG, + USER_LINE, + THINKING_LINE, + TOOL_USE_LINE, + TOOL_RESULT_LINE, + ANSWER_LINE, + ], + ); + + let detail = parser_in(tmp.path()).get_conversation("s1").unwrap(); + assert_eq!(detail.summary.id, "s1"); + assert_eq!(detail.transcript_watermark, Some(std::fs::metadata(&path).unwrap().len())); + + // One user turn, then TWO assistant turns: the thinking + tool_use + // fragments share a `message.id` and merge into one message whose + // paired tool result is absorbed, and the end-turn answer (a fresh + // `message.id`) forms the next turn. + assert_eq!(detail.turns.len(), 3); + assert!(matches!(detail.turns[0].role, crate::models::TurnRole::User)); + assert!(matches!(detail.turns[1].role, crate::models::TurnRole::Assistant)); + assert!(matches!(detail.turns[2].role, crate::models::TurnRole::Assistant)); + + let assistant = &detail.turns[1]; + let tool_use = assistant + .blocks + .iter() + .find_map(|b| match b { + ContentBlock::ToolUse { tool_use_id, tool_name, .. } => { + Some((tool_use_id.clone(), tool_name.clone())) + } + _ => None, + }) + .expect("tool_use block"); + assert_eq!( + tool_use, + (Some("call_1".to_string()), "Read".to_string()) + ); + let tool_result = assistant + .blocks + .iter() + .find_map(|b| match b { + ContentBlock::ToolResult { tool_use_id, output_preview, .. } => { + Some((tool_use_id.clone(), output_preview.clone())) + } + _ => None, + }) + .expect("tool_result block paired into the turn"); + assert_eq!(tool_result.0.as_deref(), Some("call_1")); + assert!(tool_result.1.as_deref().unwrap().contains("42")); + assert!(assistant + .blocks + .iter() + .any(|b| matches!(b, ContentBlock::Thinking { text } if text.contains("read the file")))); + assert!(assistant + .blocks + .iter() + .all(|b| !matches!(b, ContentBlock::Text { text } if text == "42"))); + let answer = &detail.turns[2]; + assert!(answer + .blocks + .iter() + .any(|b| matches!(b, ContentBlock::Text { text } if text == "42"))); + assert_eq!(answer.usage.as_ref().map(|u| u.output_tokens), Some(2)); + } + + #[test] + fn sidechain_entries_are_excluded() { + let tmp = tempfile::tempdir().unwrap(); + write_session( + tmp.path(), + "s1", + &[USER_LINE, ANSWER_LINE, SIDECHAIN_LINE], + ); + + let detail = parser_in(tmp.path()).get_conversation("s1").unwrap(); + assert_eq!(detail.summary.message_count, 2); + assert!(detail.turns.iter().all(|t| t + .blocks + .iter() + .all(|b| !matches!(b, ContentBlock::Text { text } if text.contains("sub-agent"))))); + } + + #[test] + fn tail_metadata_records_do_not_extend_timestamps() { + let tmp = tempfile::tempdir().unwrap(); + write_session( + tmp.path(), + "s1", + &[USER_LINE, ANSWER_LINE, ACTIVE_LEAF_LINE, RUNTIME_CONFIG], + ); + + let summaries = parser_in(tmp.path()).list_conversations().unwrap(); + // `active-leaf` carries a LATER epoch than any content record; it must + // not leak into `ended_at`. + assert_eq!( + summaries[0].ended_at.as_ref().map(|t| t.to_rfc3339()), + Some("2026-08-16T15:45:34+00:00".to_string()) + ); + } + + #[test] + fn missing_session_is_not_found() { + let tmp = tempfile::tempdir().unwrap(); + let err = parser_in(tmp.path()).get_conversation("nope").unwrap_err(); + assert!(matches!(err, ParseError::ConversationNotFound(_))); + } + + #[test] + fn config_dir_env_overrides_user_home() { + let resolved = resolve_qoder_config_dir_from( + Some("/tmp/qoder-home".into()), + Some("/Users/default".into()), + ); + assert_eq!(resolved, PathBuf::from("/tmp/qoder-home")); + // An empty override falls back to `~/.qoder`, not an empty path. + let resolved = resolve_qoder_config_dir_from(Some("".into()), Some("/Users/default".into())); + assert_eq!(resolved, PathBuf::from("/Users/default/.qoder")); + let resolved = resolve_qoder_config_dir_from(None, Some("/Users/default".into())); + assert_eq!(resolved, PathBuf::from("/Users/default/.qoder")); + } +} diff --git a/src/components/agent-icon.tsx b/src/components/agent-icon.tsx index 05a0a80f3..0e69e1549 100644 --- a/src/components/agent-icon.tsx +++ b/src/components/agent-icon.tsx @@ -395,6 +395,26 @@ const GrokMonoIcon = memo(function GrokMonoIcon({ size = "1em" }: IconProps) { ) }) +const QoderMonoIcon = memo(function QoderMonoIcon({ size = "1em" }: IconProps) { + // Qoder's Q mark — the official glyph from the ACP registry + // (cdn.agentclientprotocol.com/registry/v1/latest/qoder.svg), already + // authored in currentColor so it renders frameless like the other mono + // marks (Cursor's icon comes from the same registry CDN). + return ( + + Qoder + + + ) +}) + const CursorMonoIcon = memo(function CursorMonoIcon({ size = "1em", }: IconProps) { @@ -436,6 +456,7 @@ const MONO_ICONS: Partial> = { code_buddy: CodeBuddyMonoIcon, grok: GrokMonoIcon, cursor: CursorMonoIcon, + qoder: QoderMonoIcon, } // Per-agent color override for mono marks, layered on top of the default diff --git a/src/components/settings/acp-agent-settings.tsx b/src/components/settings/acp-agent-settings.tsx index 4b3299265..829f7e431 100644 --- a/src/components/settings/acp-agent-settings.tsx +++ b/src/components/settings/acp-agent-settings.tsx @@ -757,6 +757,15 @@ function importantEnvKeysByAgent(agentType: AgentType): ImportantEnvKeys { model: ["DEEPSEEK_ACP_MODEL"], } } + if (agentType === "qoder") { + // Qoder authenticates as the qoder account (`qoder login` / the IDE's + // qoder-browser flow); no env var is a real credential, and the generic + // OPENAI_*/API_KEY fallback would let the auth panel report "configured" + // off a key qoder never reads. Empty lists keep the panel honest — the + // sign-in command is the path, mirrors the backend `agent_env_keys(Qoder)` + // placeholders. + return { apiBaseUrl: [], apiKey: [], model: [] } + } return { apiBaseUrl: ["OPENAI_BASE_URL", "API_BASE_URL"], apiKey: ["OPENAI_API_KEY", "API_KEY"], diff --git a/src/components/settings/delegation-agent-defaults.tsx b/src/components/settings/delegation-agent-defaults.tsx index 316813051..dd92286cb 100644 --- a/src/components/settings/delegation-agent-defaults.tsx +++ b/src/components/settings/delegation-agent-defaults.tsx @@ -69,6 +69,7 @@ const BUILTIN_AGENT_TYPES: AgentType[] = [ "grok", "cursor", "deepseek", + "qoder", ] interface CachedSnapshot { diff --git a/src/components/settings/mcp-settings.tsx b/src/components/settings/mcp-settings.tsx index 92554bda7..50fc8d068 100644 --- a/src/components/settings/mcp-settings.tsx +++ b/src/components/settings/mcp-settings.tsx @@ -99,6 +99,7 @@ const APP_OPTIONS: { value: McpAppType; label: string }[] = [ { value: "grok", label: "Grok" }, { value: "cursor", label: "Cursor" }, { value: "deepseek", label: "DeepSeek Harness" }, + { value: "qoder", label: "Qoder" }, ] function isObject(value: unknown): value is Record { @@ -266,6 +267,7 @@ function appsToDraft(apps: McpAppType[]): Record { grok: appSet.has("grok"), cursor: appSet.has("cursor"), deepseek: appSet.has("deepseek"), + qoder: appSet.has("qoder"), } } diff --git a/src/lib/types.ts b/src/lib/types.ts index f57a24ecf..e13d183f2 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -13,6 +13,7 @@ export type BuiltinAgentType = | "grok" | "cursor" | "deepseek" + | "qoder" /** * Which agent backs a conversation. @@ -696,6 +697,7 @@ export const AGENT_DISPLAY_ORDER: BuiltinAgentType[] = [ "grok", "cursor", "deepseek", + "qoder", ] const AGENT_DISPLAY_ORDER_INDEX = new Map( @@ -728,6 +730,7 @@ export const ALL_AGENT_TYPES: BuiltinAgentType[] = [ "grok", "cursor", "deepseek", + "qoder", ] export const MODEL_PROVIDER_AGENT_TYPES: BuiltinAgentType[] = [ @@ -1037,6 +1040,7 @@ export const AGENT_LABELS: Record = { grok: "Grok", cursor: "Cursor", deepseek: "DeepSeek Harness", + qoder: "Qoder", } export const AGENT_COLORS: Record = { @@ -1053,6 +1057,7 @@ export const AGENT_COLORS: Record = { grok: "bg-neutral-900", cursor: "bg-zinc-800", deepseek: "bg-[#4D6BFE]", + qoder: "bg-[#6C4CF1]", } // ACP connection status (matches Rust ConnectionStatus) @@ -2980,6 +2985,7 @@ export type McpAppType = | "grok" | "cursor" | "deepseek" + | "qoder" export interface LocalMcpServer { id: string