From 9f246bc55b461ae64ebcd2462075b55651eb453f Mon Sep 17 00:00:00 2001 From: idevlab Date: Fri, 28 Aug 2026 20:24:16 +0800 Subject: [PATCH 1/2] refactor: separate core plugins and desktop shell --- Cargo.lock | 27 +- Cargo.toml | 2 + README.md | 11 +- apps/desktop/src-host/Cargo.toml | 3 +- apps/desktop/src-host/src/automation.rs | 2 +- apps/desktop/src-host/src/device_sync.rs | 2 +- apps/desktop/src-host/src/host_events.rs | 4 +- apps/desktop/src-host/src/lib.rs | 6 +- apps/desktop/src-host/src/lsp.rs | 6 +- apps/desktop/src-host/src/remote.rs | 16 +- apps/desktop/src-host/src/scene_mcp.rs | 2 +- apps/desktop/src/App.tsx | 93 +- apps/desktop/src/bridge.ts | 13 +- apps/desktop/src/browser/Browser.tsx | 6 +- apps/desktop/src/browser/electrobun.ts | 3 +- apps/desktop/src/container.ts | 73 + apps/desktop/src/dock/Dock.tsx | 410 +-- apps/desktop/src/electrobun/index.ts | 4 +- apps/desktop/src/files/FileDockContent.tsx | 106 + apps/desktop/src/git/GitDockContent.tsx | 85 + apps/desktop/src/pet/DesktopPet.tsx | 4 +- apps/desktop/src/session/MarkdownContent.tsx | 4 +- .../desktop/src/settings/AppshotsSettings.tsx | 236 ++ .../src/settings/OperationalSettings.tsx | 575 ++++ .../desktop/src/settings/PersonalSettings.tsx | 352 +++ apps/desktop/src/settings/ProjectSettings.tsx | 509 ++++ .../desktop/src/settings/ProviderSettings.tsx | 310 ++ apps/desktop/src/settings/SettingsPage.tsx | 2533 +---------------- .../src/settings/SettingsPrimitives.tsx | 80 + .../desktop/src/settings/WorktreeSettings.tsx | 393 +++ apps/desktop/src/sidebar/SessionRail.tsx | 8 +- apps/desktop/src/styles.css | 11 + .../src/terminal/TerminalDockContent.tsx | 145 + apps/desktop/tests/appshotsContract.test.ts | 6 +- apps/desktop/tests/containerBoundary.test.ts | 53 + apps/desktop/tests/dockArchitecture.test.ts | 40 + .../tests/dockPluginGateRendered.test.tsx | 29 +- .../tests/pluginBridgeContract.test.ts | 4 +- .../tests/settingsLayoutContract.test.ts | 35 +- apps/desktop/tests/t3RemoteContract.test.ts | 2 +- .../tests/windowChromeContract.test.ts | 21 +- crates/core/Cargo.toml | 9 +- crates/core/src/harness.rs | 110 +- crates/core/src/lib.rs | 14 +- crates/core/src/memory.rs | 4 - crates/core/tests/architecture_boundary.rs | 64 + crates/plugins/Cargo.toml | 27 + .../examples/validate_bundle.rs | 2 +- .../examples/validate_marketplace.rs | 2 +- .../agent-plugins/1.0.0/mcp.schema.json | 0 .../agent-plugins/1.0.0/plugin.schema.json | 0 .../src/app/bundle_runtime.rs | 2 +- crates/{core => plugins}/src/app/events.rs | 2 +- crates/{core => plugins}/src/app/mod.rs | 59 +- .../src/app/plugin_config.rs | 0 .../src/app/plugin_manager.rs | 2 +- .../src/app/plugins/canvas.rs | 44 +- .../src/app/plugins/engine.rs | 86 +- .../src/app/plugins/extensions.rs | 0 .../src/app/plugins/foundation.rs | 8 +- .../src/app/plugins/handoff.rs | 2 +- .../{core => plugins}/src/app/plugins/hub.rs | 8 +- .../src/app/plugins/issues.rs | 6 +- .../src/app/plugins/library.rs | 30 +- .../src/app/plugins/memory.rs | 8 +- .../{core => plugins}/src/app/plugins/mod.rs | 0 .../src/app/plugins/plugin_development.rs | 2 +- .../src/app/plugins/runtime.rs | 8 +- .../src/app/plugins/scene_commands.rs | 16 +- .../src/app/plugins/terminal.rs | 4 +- .../src/app/plugins/utility.rs | 64 +- .../src/app/plugins/workspace.rs | 22 +- .../src/app/plugins/workspace_io.rs | 47 +- .../{core => plugins}/src/app/protocol/mod.rs | 8 +- .../src/app/protocol/peer.rs | 2 +- .../src/app/protocol/wire.rs | 0 crates/{core => plugins}/src/app/service.rs | 74 +- .../src/plugin.rs => plugins/src/bundle.rs} | 30 +- crates/plugins/src/lib.rs | 36 + .../src/marketplace.rs} | 0 crates/{core => plugins}/tests/app_graph.rs | 9 +- .../tests/memory_plugin_lifecycle.rs | 7 +- .../{core => plugins}/tests/plugin_config.rs | 2 +- .../{core => plugins}/tests/plugin_manager.rs | 13 +- .../tests/plugin_metadata.rs | 2 +- .../tests/plugin_process_lifecycle.rs | 4 +- .../tests/plugin_protocol.rs | 9 +- .../tests/plugin_registry_unknown.rs | 2 +- .../tests/project_bundle_runtime.rs | 9 +- .../tests/project_plugin_graph.rs | 11 +- .../tests/tool_broker_adapter.rs | 4 +- crates/server/Cargo.toml | 3 +- crates/server/src/bin/codetwo-agent.rs | 2 +- crates/server/src/main.rs | 2 +- crates/tui/Cargo.toml | 1 + crates/tui/src/main.rs | 20 +- docs/adr/0002-core-extension-boundary.md | 14 +- docs/architecture.md | 56 +- docs/plugin-protocol.md | 2 +- docs/plugin-standard.md | 8 +- docs/plugins.md | 38 +- ...scode-extension-architecture-2026-08-26.md | 8 +- docs/roadmap.md | 2 +- .../plans/2026-08-26-plugin-hot-reload.md | 22 +- 104 files changed, 3998 insertions(+), 3278 deletions(-) create mode 100644 apps/desktop/src/container.ts create mode 100644 apps/desktop/src/files/FileDockContent.tsx create mode 100644 apps/desktop/src/git/GitDockContent.tsx create mode 100644 apps/desktop/src/settings/AppshotsSettings.tsx create mode 100644 apps/desktop/src/settings/OperationalSettings.tsx create mode 100644 apps/desktop/src/settings/PersonalSettings.tsx create mode 100644 apps/desktop/src/settings/ProjectSettings.tsx create mode 100644 apps/desktop/src/settings/ProviderSettings.tsx create mode 100644 apps/desktop/src/settings/SettingsPrimitives.tsx create mode 100644 apps/desktop/src/settings/WorktreeSettings.tsx create mode 100644 apps/desktop/src/terminal/TerminalDockContent.tsx create mode 100644 apps/desktop/tests/containerBoundary.test.ts create mode 100644 apps/desktop/tests/dockArchitecture.test.ts create mode 100644 crates/core/tests/architecture_boundary.rs create mode 100644 crates/plugins/Cargo.toml rename crates/{core => plugins}/examples/validate_bundle.rs (86%) rename crates/{core => plugins}/examples/validate_marketplace.rs (92%) rename crates/{core => plugins}/schemas/agent-plugins/1.0.0/mcp.schema.json (100%) rename crates/{core => plugins}/schemas/agent-plugins/1.0.0/plugin.schema.json (100%) rename crates/{core => plugins}/src/app/bundle_runtime.rs (99%) rename crates/{core => plugins}/src/app/events.rs (97%) rename crates/{core => plugins}/src/app/mod.rs (93%) rename crates/{core => plugins}/src/app/plugin_config.rs (100%) rename crates/{core => plugins}/src/app/plugin_manager.rs (99%) rename crates/{core => plugins}/src/app/plugins/canvas.rs (93%) rename crates/{core => plugins}/src/app/plugins/engine.rs (92%) rename crates/{core => plugins}/src/app/plugins/extensions.rs (100%) rename crates/{core => plugins}/src/app/plugins/foundation.rs (98%) rename crates/{core => plugins}/src/app/plugins/handoff.rs (98%) rename crates/{core => plugins}/src/app/plugins/hub.rs (99%) rename crates/{core => plugins}/src/app/plugins/issues.rs (97%) rename crates/{core => plugins}/src/app/plugins/library.rs (94%) rename crates/{core => plugins}/src/app/plugins/memory.rs (97%) rename crates/{core => plugins}/src/app/plugins/mod.rs (100%) rename crates/{core => plugins}/src/app/plugins/plugin_development.rs (99%) rename crates/{core => plugins}/src/app/plugins/runtime.rs (98%) rename crates/{core => plugins}/src/app/plugins/scene_commands.rs (98%) rename crates/{core => plugins}/src/app/plugins/terminal.rs (98%) rename crates/{core => plugins}/src/app/plugins/utility.rs (55%) rename crates/{core => plugins}/src/app/plugins/workspace.rs (92%) rename crates/{core => plugins}/src/app/plugins/workspace_io.rs (95%) rename crates/{core => plugins}/src/app/protocol/mod.rs (98%) rename crates/{core => plugins}/src/app/protocol/peer.rs (98%) rename crates/{core => plugins}/src/app/protocol/wire.rs (100%) rename crates/{core => plugins}/src/app/service.rs (90%) rename crates/{core/src/plugin.rs => plugins/src/bundle.rs} (99%) create mode 100644 crates/plugins/src/lib.rs rename crates/{core/src/plugin_marketplace.rs => plugins/src/marketplace.rs} (100%) rename crates/{core => plugins}/tests/app_graph.rs (98%) rename crates/{core => plugins}/tests/memory_plugin_lifecycle.rs (89%) rename crates/{core => plugins}/tests/plugin_config.rs (99%) rename crates/{core => plugins}/tests/plugin_manager.rs (99%) rename crates/{core => plugins}/tests/plugin_metadata.rs (97%) rename crates/{core => plugins}/tests/plugin_process_lifecycle.rs (97%) rename crates/{core => plugins}/tests/plugin_protocol.rs (99%) rename crates/{core => plugins}/tests/plugin_registry_unknown.rs (96%) rename crates/{core => plugins}/tests/project_bundle_runtime.rs (99%) rename crates/{core => plugins}/tests/project_plugin_graph.rs (99%) rename crates/{core => plugins}/tests/tool_broker_adapter.rs (94%) diff --git a/Cargo.lock b/Cargo.lock index 46be0fa3..3a4b1cf5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -295,10 +295,8 @@ dependencies = [ "block2", "chrono", "chrono-tz", - "codetwo-kernel", "image", "libghostty-vt", - "notify", "objc2", "objc2-app-kit", "objc2-foundation", @@ -326,6 +324,7 @@ dependencies = [ "blake3", "codetwo-core", "codetwo-kernel", + "codetwo-plugins", "codetwo-server", "futures-util", "reqwest", @@ -349,6 +348,28 @@ dependencies = [ "tracing", ] +[[package]] +name = "codetwo-plugins" +version = "0.0.0" +dependencies = [ + "async-trait", + "base64", + "blake3", + "chrono", + "codetwo-core", + "codetwo-kernel", + "notify", + "rusqlite", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.19", + "tokio", + "tracing", + "url", + "uuid", +] + [[package]] name = "codetwo-server" version = "0.0.0" @@ -356,6 +377,7 @@ dependencies = [ "axum", "chrono", "codetwo-core", + "codetwo-plugins", "futures-util", "libc", "qrcode", @@ -375,6 +397,7 @@ name = "codetwo-tui" version = "0.0.0" dependencies = [ "codetwo-core", + "codetwo-plugins", "ratatui", "tokio", "uuid", diff --git a/Cargo.toml b/Cargo.toml index 0bc629f5..c3732632 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ resolver = "2" members = [ "crates/kernel", "crates/core", + "crates/plugins", "crates/tui", "crates/server", "apps/desktop/src-host", @@ -32,6 +33,7 @@ tracing = "0.1" # Local crates codetwo-kernel = { path = "crates/kernel" } codetwo-core = { path = "crates/core" } +codetwo-plugins = { path = "crates/plugins" } [patch.crates-io] # 0.2.1 links Ghostty's Windows static archive under the Unix library name. Keep the published diff --git a/README.md b/README.md index a71851bd..6fd4bbbb 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,8 @@ whole turn, and only then send it to the agent you choose. - **Git-aware execution.** Use per-session worktrees, automatic checkpoints, diffs, revert, and explicit commit/push flows. - **Three surfaces.** C2 ships an Electrobun desktop app, a ratatui TUI, and a paired remote web - client. All three use the same Rust core and Plugin Kernel; Electrobun is the desktop shell and - relays one command/event protocol to its bundled Rust host. + client. All three compose the same Rust Core through the same plugin runtime; Electrobun is the + desktop shell and relays one command/event protocol to its bundled Rust host. ## How it fits together @@ -50,7 +50,9 @@ Claude Code · Codex · Grok · Cursor · OpenCode 1 · OpenCode 2 · Pi · Kimi │ ACP over stdio │ - Rust core + plugin kernel + Rust product core + │ + Plugin composition layer ┌─────────┼─────────┐ │ │ │ Desktop TUI Remote @@ -150,7 +152,8 @@ tailnet; C2 does not provide a hosted relay. | Path | Purpose | | -------------------------------- | --------------------------------------------------------------------------- | | [`crates/kernel`](crates/kernel) | Reactive plugin runtime and command registry | -| [`crates/core`](crates/core) | ACP engine, sessions, providers, memory, git, terminal, browser, and skills | +| [`crates/core`](crates/core) | Plugin-independent product domain: ACP, sessions, providers, policy, and persistence | +| [`crates/plugins`](crates/plugins) | Core adapters, built-in runtime graph, extension bundles, protocol, and marketplace | | [`crates/tui`](crates/tui) | ratatui frontend | | [`crates/server`](crates/server) | Headless server, pairing, WebSocket protocol, and remote client | | [`apps/desktop`](apps/desktop) | Electrobun + React + BlockNote desktop app | diff --git a/apps/desktop/src-host/Cargo.toml b/apps/desktop/src-host/Cargo.toml index 7a65903b..5355a35a 100644 --- a/apps/desktop/src-host/Cargo.toml +++ b/apps/desktop/src-host/Cargo.toml @@ -4,7 +4,7 @@ version.workspace = true edition.workspace = true license.workspace = true rust-version.workspace = true -description = "C2 native sidecar — a JSON-lines bridge over codetwo-core." +description = "C2 native sidecar — a JSON-lines desktop container over CoreApp." [dependencies] serde.workspace = true @@ -14,6 +14,7 @@ uuid.workspace = true blake3 = "1" codetwo-core.workspace = true codetwo-kernel.workspace = true +codetwo-plugins.workspace = true codetwo-server = { path = "../../../crates/server" } futures-util = "0.3" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } diff --git a/apps/desktop/src-host/src/automation.rs b/apps/desktop/src-host/src/automation.rs index 9057dbe3..fa629304 100644 --- a/apps/desktop/src-host/src/automation.rs +++ b/apps/desktop/src-host/src/automation.rs @@ -7,7 +7,6 @@ use std::sync::Arc; use std::time::Duration; -use codetwo_core::app::{EngineService, EventBus, StoreService}; use codetwo_core::permission::ExecutionPolicy; use codetwo_core::session::SessionRunState; use codetwo_core::skill::DocBlock; @@ -18,6 +17,7 @@ use codetwo_core::{ use codetwo_kernel::{ async_trait, Context, Injection, Plugin, PluginError, PluginResult, WeakContext, }; +use codetwo_plugins::{EngineService, EventBus, StoreService}; use serde::Deserialize; use serde_json::Value; use tokio::sync::broadcast; diff --git a/apps/desktop/src-host/src/device_sync.rs b/apps/desktop/src-host/src/device_sync.rs index 9562dc2f..c3d23e34 100644 --- a/apps/desktop/src-host/src/device_sync.rs +++ b/apps/desktop/src-host/src/device_sync.rs @@ -9,13 +9,13 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::Duration; -use codetwo_core::app::StoreService; use codetwo_core::device_sync::{ device_sync_snapshot_version, merge_device_sync_documents, DeviceSyncCounts, DeviceSyncDocument, }; use codetwo_core::session::now_millis; use codetwo_core::Store; use codetwo_kernel::{async_trait, Context, Injection, Plugin, PluginError, PluginResult, Service}; +use codetwo_plugins::StoreService; use futures_util::future::join_all; use reqwest::{Client, Response, Url}; use serde::de::DeserializeOwned; diff --git a/apps/desktop/src-host/src/host_events.rs b/apps/desktop/src-host/src/host_events.rs index 18d72f6e..723dc4f2 100644 --- a/apps/desktop/src-host/src/host_events.rs +++ b/apps/desktop/src-host/src/host_events.rs @@ -1,10 +1,10 @@ //! Scope-owned forwarding from core broadcasts to the desktop host protocol. -use codetwo_core::app::events::PluginsChanged; -use codetwo_core::app::{EventBus, TerminalEvent, TerminalOutputEvent}; use codetwo_kernel::{ async_trait, CommandRealm, Context, Injection, Plugin, PluginError, PluginResult, }; +use codetwo_plugins::events::PluginsChanged; +use codetwo_plugins::{EventBus, TerminalEvent, TerminalOutputEvent}; use serde::Serialize; use serde_json::Value; use tokio::sync::broadcast; diff --git a/apps/desktop/src-host/src/lib.rs b/apps/desktop/src-host/src/lib.rs index 47aed0f5..38d67b31 100644 --- a/apps/desktop/src-host/src/lib.rs +++ b/apps/desktop/src-host/src/lib.rs @@ -17,12 +17,12 @@ mod scene_mcp; use std::path::PathBuf; use std::sync::Arc; -use codetwo_core::app::plugins::{EngineInputs, EnginePlugin}; -use codetwo_core::app::{AppConfig, CoreApp}; use codetwo_core::{CanvasFeatureGate, DesktopMcpConfig, Engine}; use codetwo_kernel::{ PluginCategory, PluginEntry, PluginMetadata, PluginOrigin, PluginRole, PluginScopeSupport, }; +use codetwo_plugins::builtins::{EngineInputs, EnginePlugin}; +use codetwo_plugins::{AppConfig, CoreApp}; use serde::{Deserialize, Serialize}; use serde_json::Value; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; @@ -152,7 +152,7 @@ pub async fn run() -> Result<(), String> { browser_enabled: false, }; - let mut registry = codetwo_core::app::plugins::builtin_registry(); + let mut registry = codetwo_plugins::builtins::builtin_registry(); #[cfg(unix)] let engine_metadata = registry .get("engine") diff --git a/apps/desktop/src-host/src/lsp.rs b/apps/desktop/src-host/src/lsp.rs index 20447479..63143258 100644 --- a/apps/desktop/src-host/src/lsp.rs +++ b/apps/desktop/src-host/src/lsp.rs @@ -12,9 +12,9 @@ use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, MutexGuard}; -use codetwo_core::app::events::PluginsChanged; -use codetwo_core::app::Paths; use codetwo_kernel::{async_trait, Context, Injection, Plugin, PluginError, PluginResult}; +use codetwo_plugins::events::PluginsChanged; +use codetwo_plugins::Paths; use serde::Deserialize; use serde::Serialize; use serde_json::Value; @@ -87,7 +87,7 @@ fn lsp_start( state.ensure_open()?; let plugins_dir = paths.plugins(); if plugins_dir.is_dir() { - let plugins = codetwo_core::plugin::load_dir(&plugins_dir).unwrap_or_default(); + let plugins = codetwo_plugins::bundle::load_dir(&plugins_dir).unwrap_or_default(); for plugin in plugins .into_iter() .filter(|plugin| plugin.enabled && plugin.trusted) diff --git a/apps/desktop/src-host/src/remote.rs b/apps/desktop/src-host/src/remote.rs index 54500c51..729989a4 100644 --- a/apps/desktop/src-host/src/remote.rs +++ b/apps/desktop/src-host/src/remote.rs @@ -6,9 +6,9 @@ use std::path::PathBuf; use std::sync::{Arc, Mutex}; -use codetwo_core::app::{CanvasService, EngineService, EventBus, StoreService}; use codetwo_core::{Engine, Event, Store}; use codetwo_kernel::{async_trait, Context, Injection, Plugin, PluginError, PluginResult}; +use codetwo_plugins::{CanvasService, EngineService, EventBus, StoreService}; use serde::{Deserialize, Serialize}; use serde_json::Value; use tokio::sync::broadcast; @@ -230,8 +230,7 @@ impl Plugin for RemotePlugin { } fn inject(&self) -> Injection { - Injection::required(["engine", "store", "bus", "canvas"]) - .with_optional(["device-sync"]) + Injection::required(["engine", "store", "bus", "canvas"]).with_optional(["device-sync"]) } fn description(&self) -> Option<&str> { @@ -295,9 +294,10 @@ impl Plugin for RemotePlugin { let auth = Arc::new(codetwo_server::AuthState::load(Some( service.auth_path.clone(), ))); - let device_sync_http = service.device_sync.clone().map(|device_sync| { - device_sync as Arc - }); + let device_sync_http = service + .device_sync + .clone() + .map(|device_sync| device_sync as Arc); let bound = codetwo_server::bind_and_serve_with_services( service.engine.clone(), service.events.clone(), @@ -481,9 +481,7 @@ impl Plugin for RemotePlugin { true } else { match &service.device_sync { - Some(sync) => sync - .revoke_device(&args.id) - .map_err(PluginError::new)?, + Some(sync) => sync.revoke_device(&args.id).map_err(PluginError::new)?, None => false, } }; diff --git a/apps/desktop/src-host/src/scene_mcp.rs b/apps/desktop/src-host/src/scene_mcp.rs index 7e3711fc..9d484800 100644 --- a/apps/desktop/src-host/src/scene_mcp.rs +++ b/apps/desktop/src-host/src/scene_mcp.rs @@ -2,7 +2,7 @@ use std::io::{BufRead, BufReader, Write}; -use codetwo_core::app::{CoreApp, SceneService, StoreService}; +use codetwo_plugins::{CoreApp, SceneService, StoreService}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 65fcbe67..22ce8969 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -234,6 +234,7 @@ import { RemoteModal } from "./remote/Remote"; import { IssuesModal } from "./issues/Issues"; import { PreviewModal } from "./editor/Preview"; import { FileBrowserModal } from "./files/FileBrowser"; +import { FileDockContent } from "./files/FileDockContent"; import { WorkspaceSearchModal } from "./files/WorkspaceSearch"; import type { FileRevealTarget } from "./files/FileViewer"; import { dirtyKey, isDirty as isFileDirty, markDirty } from "./files/dirty"; @@ -380,6 +381,10 @@ import { type DockSurface, type DockTab, } from "./dock/Dock"; +import { BrowserPanel } from "./browser/Browser"; +import { GitDockContent } from "./git/GitDockContent"; +import { TerminalDockContent } from "./terminal/TerminalDockContent"; +import { TrajectoryView } from "./session/TrajectoryView"; import { SessionRail } from "./sidebar/SessionRail"; import { EnvironmentPopover } from "./environment/EnvironmentPopover"; import { MissionControlDialog } from "./sidebar/MissionControl.tsx"; @@ -7768,12 +7773,11 @@ export default function App() {
{/* Also a window drag region: the overlay title bar draws nothing to grab. Buttons and other children stay clickable — only elements carrying the attribute start a drag. */} - {/* The shared titlebar height centres the 28px controls on the same 48px line as the rail - and dock headers. With the rail collapsed, the inset clears - the traffic lights and the expand button takes the wordmark's place. */} + {/* The shared 40px title line keeps every pane on one baseline. With the rail collapsed, + the inset clears the traffic lights and the expand button takes the wordmark's place. */}
@@ -8293,36 +8297,59 @@ export default function App() { }} onClose={() => manualDockTab(null)} autoTab={dockAutoHint?.surface ?? null} - highlightFile={dockAutoHint?.file ?? null} - cwd={cwd || null} - projectPath={ - activeProject ? normalizePluginProjectPath(activeProject) : null - } - sessionKey={activeSession ?? "main"} - git={git} - onRefreshGit={refreshGit} - onOpenSourceControl={openSourceControl} - browserUrl={browserUrl} - onNavigate={setBrowserUrl} - onAnnotate={(n) => void annotate(n)} - onInsertFile={(p) => insertFileRef.current?.(p)} - onSendText={(text) => insertTextRef.current?.(text)} - onOpenFile={openFileTab} - openFiles={openFiles} - activeFile={activeFile} - fileReveal={fileReveal} - onActiveFile={(path) => { - setActiveFile(path); - setFileReveal(null); + content={{ + trajectory: ( + void loadEarlierTranscript(paneLayout.focused)} + /> + ), + browser: ( + void annotate(notes)} + /> + ), + terminal: ( + insertTextRef.current?.(text)} + /> + ), + files: ( + { + setActiveFile(path); + setFileReveal(null); + }} + onCloseFile={closeFileTab} + onInsertFile={(path) => insertFileRef.current?.(path)} + onOpenFile={openFileTab} + onSendText={(text) => insertTextRef.current?.(text)} + /> + ), + git: ( + + ), }} - onCloseFile={closeFileTab} - turns={turns} - usage={sessionUsage} - hasEarlier={focusedTranscriptState.nextBefore !== null} - loadingEarlier={focusedTranscriptState.loadingEarlier} - onLoadEarlier={() => - void loadEarlierTranscript(paneLayout.focused) - } width={dockWidth} onWidth={setDockWidth} reservedWidth={railInlineWidth} diff --git a/apps/desktop/src/bridge.ts b/apps/desktop/src/bridge.ts index 67f60a6d..c73d1ae9 100644 --- a/apps/desktop/src/bridge.ts +++ b/apps/desktop/src/bridge.ts @@ -20,8 +20,9 @@ import { desktopUpdateStatus, isElectrobun, listenDesktop, -} from "./electrobun/client"; -import { onDesktopAppshotCaptured, onDesktopAppshotFailed } from "./electrobun/client"; + onDesktopAppshotCaptured, + onDesktopAppshotFailed, +} from "./container"; import type { AppshotCapture, AppshotDestination, @@ -29,7 +30,7 @@ import type { AppshotSettings, AppUpdateStatus, WorkspaceOpenTarget, -} from "./electrobun/rpc"; +} from "./container"; import type { PluginRuntimeCommandContribution, PluginUiContribution } from "./pluginModel"; import { browserAnnotateLocal, @@ -51,9 +52,9 @@ import { browserVisibleLocal, browserZoomLocal, type EmbeddedBrowserTab, -} from "./browser/electrobun"; +} from "./container"; -// Typed renderer bridge to the Rust Plugin Kernel through Electrobun's desktop adapter. +// Product-facing content bridge. Native shell details stay behind `container.ts`. export type { AppshotCapture, @@ -80,7 +81,7 @@ let systemProfileAvatarRequest: Promise | null = null; export function systemProfileAvatar(): Promise { if (!inDesktop) return Promise.resolve(null); - systemProfileAvatarRequest ??= desktopSystemProfileAvatar(); + if (!systemProfileAvatarRequest) systemProfileAvatarRequest = desktopSystemProfileAvatar(); return systemProfileAvatarRequest; } diff --git a/apps/desktop/src/browser/Browser.tsx b/apps/desktop/src/browser/Browser.tsx index efc20c46..4183c057 100644 --- a/apps/desktop/src/browser/Browser.tsx +++ b/apps/desktop/src/browser/Browser.tsx @@ -1,5 +1,4 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; -import type { WebviewTagElement } from "electrobun/view"; import { ArrowLeft, ArrowRight, @@ -60,7 +59,7 @@ import { type BrowserHistoryState, type StorageLike, } from "./history"; -import { embeddedBrowserRenderer, registerBrowserWebview } from "./electrobun"; +import { embeddedBrowserRenderer, registerBrowserWebview } from "../container"; const BLANK = "about:blank"; @@ -168,8 +167,7 @@ function BrowserWebview({ visible: boolean; }) { const connect = useCallback( - (element: HTMLElement | null) => - registerBrowserWebview(label, element as WebviewTagElement | null), + (element: HTMLElement | null) => registerBrowserWebview(label, element), [label], ); return ( diff --git a/apps/desktop/src/browser/electrobun.ts b/apps/desktop/src/browser/electrobun.ts index d7d40b21..c6dc9807 100644 --- a/apps/desktop/src/browser/electrobun.ts +++ b/apps/desktop/src/browser/electrobun.ts @@ -253,7 +253,8 @@ function detach(label: string, view: WebviewTagElement): void { } /** Connect a React-rendered `` to the browser command surface. */ -export function registerBrowserWebview(label: string, view: WebviewTagElement | null): void { +export function registerBrowserWebview(label: string, element: HTMLElement | null): void { + const view = element as WebviewTagElement | null; const previous = views.get(label); if (previous && previous !== view) detach(label, previous); if (!view) { diff --git a/apps/desktop/src/container.ts b/apps/desktop/src/container.ts new file mode 100644 index 00000000..17c38729 --- /dev/null +++ b/apps/desktop/src/container.ts @@ -0,0 +1,73 @@ +/** + * The renderer's only import boundary to the desktop container. + * + * Product content imports native capabilities from here. Electrobun RPC, native context menus, + * embedded webviews, windows, dialogs, updates, and pets stay behind this module so the content + * tree does not depend on a particular desktop shell. + */ +export { + desktopAppshotSettings, + desktopCall, + desktopCaptureAppshot, + desktopCheckForUpdates, + desktopConfirm, + desktopGetAppshot, + desktopGetPetState, + desktopHidePet, + desktopOpenAppshotPrivacySettings, + desktopOpenDevtools, + desktopOpenDialog, + desktopOpenExternal, + desktopOpenPath, + desktopOpenWorkspace, + desktopRequestAppshotPermissions, + desktopSaveDialog, + desktopSendPetVoiceText, + desktopSetSystemBadgeCount, + desktopShowItemInFolder, + desktopSystemProfileAvatar, + desktopUpdateAppshotSettings, + desktopUpdatePetState, + desktopUpdateStatus, + isElectrobun, + listenDesktop, + onDesktopAppshotCaptured, + onDesktopAppshotFailed, +} from "./electrobun/client"; +export { + nativeContextMenusAvailable, + showNativeContextMenu, +} from "./electrobun/contextMenu"; +export type { + AppshotCapture, + AppshotDestination, + AppshotHotkey, + AppshotSettings, + AppUpdateStatus, + DesktopPetState, + NativeContextMenuItem, + WorkspaceOpenTarget, +} from "./electrobun/rpc"; +export { + browserAnnotateLocal, + browserAnnotationCountLocal, + browserAnnotationsClearLocal, + browserAnnotationsLocal, + browserBoundsLocal, + browserCloseAllLocal, + browserCloseLocal, + browserDevtoolsLocal, + browserHistoryLocal, + browserNavigateLocal, + browserOpenLocal, + browserRegistryCreateLocal, + browserRegistrySnapshotLocal, + browserReloadLocal, + browserSubscribe, + browserTakeControlLocal, + browserVisibleLocal, + browserZoomLocal, + embeddedBrowserRenderer, + registerBrowserWebview, +} from "./browser/electrobun"; +export type { EmbeddedBrowserTab } from "./browser/electrobun"; diff --git a/apps/desktop/src/dock/Dock.tsx b/apps/desktop/src/dock/Dock.tsx index a71048d0..8d3f98b5 100644 --- a/apps/desktop/src/dock/Dock.tsx +++ b/apps/desktop/src/dock/Dock.tsx @@ -1,40 +1,53 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { Activity, - CornerUpLeft, - FileText, FolderTree, GitBranch, Globe, MessageSquare, - Plus, TerminalIcon, X, } from "@/components/ui/icons"; -import { BrowserPanel } from "../browser/Browser"; -import { TerminalPanel } from "../terminal/Terminal"; -import { FilePanel } from "../files/FilePanel"; -import { FileViewer, type FileRevealTarget } from "../files/FileViewer"; -import { dirtyKey, useDirtyPaths } from "../files/dirty"; -import { onPtyTitle, ptyDump, ptyKill, type Annotation, type GitStatus } from "../bridge"; -import { GitHubPullRequestPanel } from "../git/GitHubPullRequestPanel"; import type { StringKey } from "../i18n/strings"; import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; -import { ScrollArea } from "@/components/ui/scroll-area"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useResizeHandle } from "@/components/ui/use-resize-handle"; import { useT } from "../i18n"; import { cn } from "@/lib/utils"; -import { TrajectoryView } from "../session/TrajectoryView"; -import type { Turn } from "../session/turns"; export type DockSurface = "trajectory" | "terminal" | "browser" | "files" | "git"; /** "home" is the dock open with nothing chosen yet — the surface picker. */ export type DockTab = DockSurface | "home"; +export type DockContentMap = Partial>; /** The picker's cards, in the order a coding session tends to want them. */ -const SURFACES: { id: DockSurface; icon: typeof Globe; titleKey: StringKey; descKey: StringKey }[] = [ +type DockSurfaceDefinition = { + id: DockSurface; + icon: typeof Globe; + titleKey: StringKey; + descKey: StringKey; +}; + +type DockProps = { + /** Whether the dock is expanded. It stays mounted while closed so shells survive. */ + open: boolean; + /** null while closed; the last surface stays rendered underneath the collapse animation. */ + tab: DockTab | null; + onTab: (surface: DockSurface) => void; + onOpenSideChat?: () => void; + onClose: () => void; + width: number; + onWidth: (width: number) => void; + /** Inline shell width that must remain beside the document while the dock is open. */ + reservedWidth?: number; + autoTab?: DockSurface | null; + /** Disabled surfaces are neither advertised nor mounted. */ + availableSurfaces?: DockSurface[]; + /** Content is inert until its matching surface is enabled and mounted by the container. */ + content?: DockContentMap; +}; + +const SURFACES: DockSurfaceDefinition[] = [ { id: "trajectory", icon: Activity, titleKey: "trajectory.label", descKey: "dock.trajectoryDesc" }, { id: "browser", icon: Globe, titleKey: "dock.browser", descKey: "dock.browserDesc" }, { id: "terminal", icon: TerminalIcon, titleKey: "dock.terminal", descKey: "dock.terminalDesc" }, @@ -57,138 +70,21 @@ export function shouldOverlayRailForDock(viewportWidth: number, railWidth: numbe return viewportWidth < railWidth + DOCK_MIN_WIDTH + DOCK_MAIN_MIN_WIDTH; } -/** - * A terminal's identity, and the reason its state survives a remount: the core keys terminals by - * this string, so the same session, slot, and mode always reach the same emulator. `tmux` is part - * of it because toggling the checkbox means "a different kind of terminal", not "reconfigure this - * one" — the emulator it names is already running a shell. - */ -function termId(sessionKey: string, slot: number, tmux: boolean): string { - return `${sessionKey}-${slot}${tmux ? "-tmux" : ""}`; -} - -/** Shell titles are usually a path or a `user@host:/long/path`; the tail is the part that says - * where you are. Falls back to the slot number until the shell tells us anything. */ -function tabLabel(title: string | undefined, slot: number): string { - if (!title) return String(slot); - return title.split("/").filter(Boolean).pop() ?? title; -} - -/** The right-side work dock shared by trajectory, terminal, browser, files, and source control. */ +/** Right-side container for navigation, sizing, animation, and caller-supplied surface content. */ export function Dock({ open, tab, onTab, onOpenSideChat, onClose, - cwd, - projectPath, - sessionKey, - git, - onRefreshGit, - onOpenSourceControl, - browserUrl, - onNavigate, - onAnnotate, - onInsertFile, - onOpenFile, - onSendText, - openFiles, - activeFile, - fileReveal, - onActiveFile, - onCloseFile, - turns, - usage, - hasEarlier, - loadingEarlier, - onLoadEarlier, width, onWidth, reservedWidth = 0, autoTab, - highlightFile, availableSurfaces = ["trajectory", "browser", "terminal", "files", "git"], -}: { - /** Whether the dock is expanded. It stays mounted while closed so shells survive and the - collapse can actually animate — unmounting was why closing used to just blink away. */ - open: boolean; - /** null while closed; the last surface stays rendered underneath the collapse animation. */ - tab: DockTab | null; - onTab: (t: DockSurface) => void; - /** Opens the app-lifetime side chat from the right-panel surface picker. */ - onOpenSideChat?: () => void; - onClose: () => void; - cwd: string | null; - /** Source project identity; distinct from `cwd` for isolated worktree sessions. */ - projectPath: string | null; - sessionKey: string; - git: GitStatus | null; - onRefreshGit: () => void; - onOpenSourceControl: () => void; - browserUrl: string; - onNavigate: (u: string) => void; - onAnnotate: (notes: Annotation[]) => void; - /** Drops an `@` mention into the prompt document. */ - onInsertFile: (path: string) => void; - /** Opens a file as a tab in this panel's viewer. */ - onOpenFile: (path: string) => void; - /** Appends a block to the prompt document — used to hand terminal output to the agent. */ - onSendText: (text: string) => void; - /** The viewer's open tabs, in open order, and which one is showing. */ - openFiles: string[]; - activeFile: string | null; - fileReveal: FileRevealTarget | null; - onActiveFile: (path: string) => void; - onCloseFile: (path: string) => void; - /** Session execution data rendered as a module inside the right work dock. */ - turns: readonly Turn[]; - usage: { input_tokens: number; output_tokens: number } | null; - hasEarlier: boolean; - loadingEarlier: boolean; - onLoadEarlier: () => void; - /** Dock width in px — dragged by the left-edge grip, persisted by the caller. */ - width: number; - onWidth: (n: number) => void; - /** Inline shell width that must remain beside the document while the dock is open. */ - reservedWidth?: number; - /** R10 dock follow: the surface the agent is working on right now — its tab gets a subtle - primary pulse, never a forced switch. */ - autoTab?: DockSurface | null; - /** The file the agent last touched, marked in the files tree while nothing is open. */ - highlightFile?: string | null; - /** Component policy gate. Disabled surfaces are neither advertised nor mounted. */ - availableSurfaces?: DockSurface[]; -}) { + content = {}, +}: DockProps) { const t = useT(); - const dirtyPaths = useDirtyPaths(); - const [terms, setTerms] = useState([1]); - const [activeTerm, setActiveTerm] = useState(1); - const [nextTerm, setNextTerm] = useState(2); - const [tmux, setTmux] = useState(false); - // What each shell calls itself (OSC 0/2) or where it is (OSC 7). A running command is a far - // better tab label than an ordinal, and the shell hands it to us for nothing. - const [termTitles, setTermTitles] = useState>({}); - - useEffect(() => { - let stop: (() => void) | null = null; - setTermTitles({}); - void (async () => { - stop = await onPtyTitle(({ id, title, project_path }) => { - if (project_path !== projectPath) return; - setTermTitles((v) => ({ ...v, [id]: title })); - }); - })(); - return () => stop?.(); - }, [projectPath]); - - const activeTermId = termId(sessionKey, activeTerm, tmux); - - /** Hand the visible terminal's scrollback to the agent. */ - const sendTerminalToAgent = useCallback(async () => { - const text = (await ptyDump(activeTermId, true)).trimEnd(); - if (text) onSendText(text); - }, [activeTermId, onSendText]); // What the panel shows: the live tab, or — while collapsing — whatever was open last, so the // content doesn't vanish mid-animation. @@ -252,7 +148,7 @@ export function Dock({ }, }); - const renderSurfaceCard = ({ id, icon: Icon, titleKey, descKey }: (typeof SURFACES)[number]) => ( + const renderSurfaceCard = ({ id, icon: Icon, titleKey, descKey }: DockSurfaceDefinition) => (
- {availableSurfaceSet.has("trajectory") && ( - - + {visibleSurfaces.map(({ id }) => ( + + {content[id]} - )} - - {/* Terminal — all instances stay mounted so switching tabs doesn't kill a shell. The strip - is the same h-9 bordered bar as the files tabs; the emulator below follows the app's - scheme, so no dark slab and no frame around it. */} - {availableSurfaceSet.has("terminal") && -
- {terms.map((n) => ( - - ))} - -
- - -
- {terms.map((n) => ( -
- -
- ))} - } - - {availableSurfaceSet.has("browser") && - - } - - {/* Files, reference-style: tabs over the viewer, and the tree in its own column on the - far right with the search box on top. */} - {availableSurfaceSet.has("files") && -
- {/* One tab per open file. Active gets the primary underline. h-9 matches the tree's - search row on the other side of the border, so the two strips read as one bar. */} -
- {openFiles.map((p) => { - const name = p.split("/").pop() ?? p; - const active = p === activeFile; - return ( - - ); - })} -
- - {activeFile && cwd ? ( - - ) : ( -
-

- {t("files.noneOpen")} -

-
- )} -
- -
- {/* R10: while no file is open in the viewer, mark the file the agent last touched — - the tree's existing openPath mechanism, not a forced reveal. */} - -
-
} - - {availableSurfaceSet.has("git") && - -
- {git?.is_repo ? ( - <> - - -
-
- -

{t("dock.workingTree")}

- - {git.branch || "?"} - - {git.ahead > 0 && ↑{git.ahead}} - {git.behind > 0 && ↓{git.behind}} -
- - {git.files.length === 0 ? ( -

{t("rail.clean")}

- ) : ( -
- {git.files.map((f) => ( -
- - {f.state.charAt(0).toUpperCase()} - - {f.path} -
- ))} -
- )} - - -
- - ) : ( -

{t("rail.notARepo")}

- )} -
-
-
} + ))} )}
diff --git a/apps/desktop/src/electrobun/index.ts b/apps/desktop/src/electrobun/index.ts index 1dea26b1..28ae8905 100644 --- a/apps/desktop/src/electrobun/index.ts +++ b/apps/desktop/src/electrobun/index.ts @@ -382,8 +382,8 @@ mainWindow.webview.on("dom-ready", () => { mainWindow.webview.executeJavascript( 'document.documentElement.classList.add("macos-window-glass")', ); - // Center the 14px native controls in the 48px title row and balance the leading clearance. - mainWindow.setWindowButtonPosition(22, 17); + // Center the 14px native controls in the shared 40px title row. + mainWindow.setWindowButtonPosition(22, 13); } rendererReady = true; rpc.send.hostStatus({ ready: true }); diff --git a/apps/desktop/src/files/FileDockContent.tsx b/apps/desktop/src/files/FileDockContent.tsx new file mode 100644 index 00000000..a860e422 --- /dev/null +++ b/apps/desktop/src/files/FileDockContent.tsx @@ -0,0 +1,106 @@ +import { FileText, X } from "@/components/ui/icons"; + +import { FilePanel } from "./FilePanel"; +import { FileViewer, type FileRevealTarget } from "./FileViewer"; +import { dirtyKey, useDirtyPaths } from "./dirty"; +import { useT } from "../i18n"; +import { cn } from "@/lib/utils"; + +type FileDockContentProps = { + cwd: string | null; + openFiles: string[]; + activeFile: string | null; + reveal: FileRevealTarget | null; + highlightFile?: string | null; + onActiveFile: (path: string) => void; + onCloseFile: (path: string) => void; + onInsertFile: (path: string) => void; + onOpenFile: (path: string) => void; + onSendText: (text: string) => void; +}; + +/** File tabs, viewer, and tree composed as one content module for the generic Dock container. */ +export function FileDockContent({ + cwd, + openFiles, + activeFile, + reveal, + highlightFile, + onActiveFile, + onCloseFile, + onInsertFile, + onOpenFile, + onSendText, +}: FileDockContentProps) { + const t = useT(); + const dirtyPaths = useDirtyPaths(); + + return ( +
+
+
+ {openFiles.map((path) => { + const name = path.split("/").pop() ?? path; + const active = path === activeFile; + return ( + + ); + })} +
+ + {activeFile && cwd ? ( + + ) : ( +
+

+ {t("files.noneOpen")} +

+
+ )} +
+ +
+ +
+
+ ); +} diff --git a/apps/desktop/src/git/GitDockContent.tsx b/apps/desktop/src/git/GitDockContent.tsx new file mode 100644 index 00000000..eb51341f --- /dev/null +++ b/apps/desktop/src/git/GitDockContent.tsx @@ -0,0 +1,85 @@ +import { GitBranch } from "@/components/ui/icons"; + +import type { GitStatus } from "../bridge"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { useT } from "../i18n"; +import { cn } from "@/lib/utils"; +import { GitHubPullRequestPanel } from "./GitHubPullRequestPanel"; + +type GitDockContentProps = { + cwd: string | null; + status: GitStatus | null; + onRefresh: () => void; + onOpenSourceControl: () => void; +}; + +/** Source-control summary rendered inside the generic Dock container. */ +export function GitDockContent({ + cwd, + status, + onRefresh, + onOpenSourceControl, +}: GitDockContentProps) { + const t = useT(); + + return ( + +
+ {status?.is_repo ? ( + <> + + +
+
+ +

{t("dock.workingTree")}

+ + {status.branch || "?"} + + {status.ahead > 0 && ↑{status.ahead}} + {status.behind > 0 && ↓{status.behind}} +
+ + {status.files.length === 0 ? ( +

{t("rail.clean")}

+ ) : ( +
+ {status.files.map((file) => ( +
+ + {file.state.charAt(0).toUpperCase()} + + + {file.path} + +
+ ))} +
+ )} + + +
+ + ) : ( +

{t("rail.notARepo")}

+ )} +
+
+ ); +} diff --git a/apps/desktop/src/pet/DesktopPet.tsx b/apps/desktop/src/pet/DesktopPet.tsx index da6613c3..cd2239d0 100644 --- a/apps/desktop/src/pet/DesktopPet.tsx +++ b/apps/desktop/src/pet/DesktopPet.tsx @@ -8,8 +8,8 @@ import { desktopUpdatePetState, isElectrobun, listenDesktop, -} from "../electrobun/client"; -import type { DesktopPetState } from "../electrobun/rpc"; + type DesktopPetState, +} from "../container"; import { CodeTwoPet } from "./CodeTwoPet"; import type { CodeTwoPetAnimation } from "./state"; diff --git a/apps/desktop/src/session/MarkdownContent.tsx b/apps/desktop/src/session/MarkdownContent.tsx index 9b725413..42784d9e 100644 --- a/apps/desktop/src/session/MarkdownContent.tsx +++ b/apps/desktop/src/session/MarkdownContent.tsx @@ -13,8 +13,8 @@ import { openExternal, openNativePath, revealNativePath } from "../bridge"; import { nativeContextMenusAvailable, showNativeContextMenu, -} from "../electrobun/contextMenu"; -import type { NativeContextMenuItem } from "../electrobun/rpc"; + type NativeContextMenuItem, +} from "../container"; import { useT, type Translate } from "../i18n"; import { currentDesktopPlatform } from "../platform"; import { ChartBlock, parseChartSpec } from "./ChartBlock"; diff --git a/apps/desktop/src/settings/AppshotsSettings.tsx b/apps/desktop/src/settings/AppshotsSettings.tsx new file mode 100644 index 00000000..78e09a5f --- /dev/null +++ b/apps/desktop/src/settings/AppshotsSettings.tsx @@ -0,0 +1,236 @@ +import { useEffect, useState } from "react"; +import { ScanText } from "@/components/ui/icons"; + +import { + getAppshotSettings, + openAppshotPrivacySettings, + requestAppshotPermissions, + takeAppshot, + updateAppshotSettings, + type AppshotSettings, +} from "../bridge"; +import { useT } from "../i18n"; +import { Button } from "@/components/ui/button"; +import { SettingRow } from "@/components/business/setting-row"; +import { SettingToggle } from "@/components/business/setting-toggle"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { GroupHeading, Page, Row } from "./SettingsPrimitives"; + +export function AppshotsSettingsPage({ + loader = getAppshotSettings, + saver = updateAppshotSettings, + permissionRequester = requestAppshotPermissions, + privacyOpener = openAppshotPrivacySettings, + capturer = takeAppshot, +}: { + loader?: () => Promise; + saver?: ( + patch: Partial>, + ) => Promise; + permissionRequester?: (kind: "screen-recording" | "accessibility") => Promise; + privacyOpener?: (kind: "screen-recording" | "accessibility") => Promise; + capturer?: () => Promise; +}) { + const t = useT(); + const [appshotSettings, setAppshotSettings] = useState(null); + const [saving, setSaving] = useState(false); + const [capturing, setCapturing] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let active = true; + setError(null); + void loader().then((next) => { + if (active) setAppshotSettings(next); + }).catch((cause) => { + if (active) setError(t("settings.appshotsLoadFailed", { error: String(cause) })); + }); + return () => { + active = false; + }; + }, [loader, t]); + + useEffect(() => { + if (!appshotSettings?.available || (appshotSettings.screen_recording && appshotSettings.accessibility)) return; + let active = true; + const timer = window.setInterval(() => { + void loader().then((next) => { + if (active) setAppshotSettings(next); + }).catch(() => {}); + }, 1000); + return () => { + active = false; + window.clearInterval(timer); + }; + }, [loader, appshotSettings?.accessibility, appshotSettings?.available, appshotSettings?.screen_recording]); + + async function save(patch: Partial>) { + setSaving(true); + setError(null); + try { + setAppshotSettings(await saver(patch)); + } catch (cause) { + setError(t("settings.appshotsSaveFailed", { error: String(cause) })); + } finally { + setSaving(false); + } + } + + async function grant(kind: "screen-recording" | "accessibility") { + setSaving(true); + setError(null); + try { + setAppshotSettings(await permissionRequester(kind)); + } catch (cause) { + setError(t("settings.appshotsPermissionFailed", { error: String(cause) })); + } finally { + setSaving(false); + } + } + + async function capture() { + setCapturing(true); + setError(null); + try { + await capturer(); + } catch (cause) { + setError(t("settings.appshotsCaptureFailed", { error: String(cause) })); + } finally { + setCapturing(false); + } + } + + const hotkeyLabel = appshotSettings?.hotkey === "both-command" + ? t("settings.appshotsHotkeyBothCommand") + : appshotSettings?.hotkey === "command-shift-2" + ? t("settings.appshotsHotkeyCommandShift2") + : t("settings.appshotsHotkeyCommandOption2"); + const destinationLabel = appshotSettings?.destination === "automatic" + ? t("settings.appshotsDestinationAutomatic") + : appshotSettings?.destination === "current" + ? t("settings.appshotsDestinationCurrent") + : t("settings.appshotsDestinationNew"); + + return ( + +
+ } + surface="card" + > + + +
+ + {error &&

{error}

} + {!appshotSettings ? ( +

{t("settings.appshotsLoading")}

+ ) : !appshotSettings.available ? ( +

+ {appshotSettings.unavailable_reason ?? t("settings.appshotsUnavailable")} +

+ ) : ( + <> + + + + + + + void save({ play_sound })} + /> + + {t("settings.appshotsPermissions")} + void grant("screen-recording")} + onOpen={() => void privacyOpener("screen-recording")} + /> + void grant("accessibility")} + onOpen={() => void privacyOpener("accessibility")} + /> + + )} +
+ ); +} + +function PermissionRow({ + label, + hint, + allowed, + onAllow, + onOpen, +}: { + label: string; + hint: string; + allowed: boolean; + onAllow: () => void; + onOpen: () => void; +}) { + const t = useT(); + return ( + + {allowed ? ( + {t("settings.appshotsAllowed")} + ) : ( + + )} + + + ); +} diff --git a/apps/desktop/src/settings/OperationalSettings.tsx b/apps/desktop/src/settings/OperationalSettings.tsx new file mode 100644 index 00000000..ec1fe7d8 --- /dev/null +++ b/apps/desktop/src/settings/OperationalSettings.tsx @@ -0,0 +1,575 @@ +import { useEffect, useRef, useState } from "react"; +import { Bug, Download, Globe, RefreshCw, Trash2 } from "@/components/ui/icons"; + +import { + browserPermissions, + browserRevokePermission, + exportRedactedDiagnostics, + getBrowserUseSettings, + getComputerUseSettings, + getDeviceSyncStatus, + getPluginDeveloperStatus, + onPluginsChanged, + openDevtools, + reloadDevelopmentPlugins, + selectBrowserUseBackend, + selectComputerUseBackend, + setAgentBrowserAccess, + setDeviceSyncEnabled, + setPluginDeveloperMode, + syncDeviceDataNow, + type BrowserUseSettings, + type ComputerUseSettings, + type DiagnosticsExportResult, + type DeviceSyncStatus, + type PluginDeveloperStatus, +} from "../bridge"; +import { useT } from "../i18n"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { SettingToggle } from "@/components/business/setting-toggle"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Spinner } from "@/components/ui/spinner"; +import { cn } from "@/lib/utils"; +import { GroupHeading, Page, Row } from "./SettingsPrimitives"; + +type BackendCopy = { + title: string; + description: string; + scope: string; + access?: string; + accessHint?: string; + backend: string; + automatic: string; + disabled: string; + backends: string; + loading: string; + available: string; + unavailable: string; + loadFailed: (error: unknown) => string; + testId: string; +}; + +function BackendSettingsPage({ + copy, + loader, + saver, + accessSaver, +}: { + copy: BackendCopy; + loader: () => Promise; + saver: (backend: string) => Promise; + accessSaver?: (enabled: boolean) => Promise; +}) { + const [settings, setSettings] = useState(null); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const copyRef = useRef(copy); + copyRef.current = copy; + + useEffect(() => { + let active = true; + setError(null); + void loader() + .then((next) => { + if (active) setSettings(next); + }) + .catch((cause) => { + if (active) setError(copyRef.current.loadFailed(cause)); + }); + return () => { + active = false; + }; + }, [loader]); + + const selection = settings?.selections["*"] ?? "automatic"; + const selectionLabel = selection === "automatic" + ? copy.automatic + : selection === "disabled" + ? copy.disabled + : settings?.backends.find((backend) => backend.id === selection)?.display_name ?? selection; + + async function save(backend: string) { + setSaving(true); + setError(null); + try { + setSettings(await saver(backend)); + } catch (cause) { + setError(copy.loadFailed(cause)); + } finally { + setSaving(false); + } + } + + async function saveAccess(enabled: boolean) { + if (!accessSaver) return; + setSaving(true); + setError(null); + try { + setSettings(await accessSaver(enabled)); + } catch (cause) { + setError(copy.loadFailed(cause)); + } finally { + setSaving(false); + } + } + + const accessEnabled = (settings as BrowserUseSettings | null)?.access_enabled ?? false; + + return ( + +

{copy.scope}

+ {error &&

{error}

} + {settings?.errors.map((message) => ( +

{message}

+ ))} + {!settings ? ( +

{copy.loading}

+ ) : ( + <> + {accessSaver && ( + + void saveAccess(enabled)} + /> + + )} + + + + + {copy.backends} + {settings.backends.map((backend) => ( + {backend.id}} + > + + + {backend.available ? copy.available : copy.unavailable} + + + ))} + + )} +
+ ); +} + +export function ComputerUseSettingsPage({ + loader = getComputerUseSettings, + saver = selectComputerUseBackend, +}: { + loader?: () => Promise; + saver?: (backend: string) => Promise; +}) { + const t = useT(); + return ( + t("settings.computerUseLoadFailed", { error: String(error) }), + testId: "computer-use", + }} + /> + ); +} + +export function BrowserUseSettingsPage({ + loader = getBrowserUseSettings, + saver = selectBrowserUseBackend, + accessSaver = setAgentBrowserAccess, +}: { + loader?: () => Promise; + saver?: (backend: string) => Promise; + accessSaver?: (enabled: boolean) => Promise; +}) { + const t = useT(); + return ( + t("settings.browserUseLoadFailed", { error: String(error) }), + testId: "browser-use", + }} + /> + ); +} + +function syncHint(t: ReturnType, status: DeviceSyncStatus | null): string { + switch (status?.state) { + case "disabled": + return status.available ? t("settings.syncReady") : t("settings.syncUnavailable"); + case "ready": + return status.last_success_at + ? t("settings.syncLastSuccess", { + time: new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }) + .format(status.last_success_at), + }) + : t("settings.syncReady"); + case "syncing": return t("settings.syncing"); + case "signed-out": return t("settings.syncSignedOut"); + case "restricted": return t("settings.syncRestricted"); + case "unsupported": return t("settings.syncUnsupported"); + case "unavailable": return t("settings.syncUnavailable"); + case "error": return status.message || t("settings.syncUnavailable"); + default: return status?.available ? t("settings.syncReady") : t("settings.syncLoading"); + } +} + +export function DeviceSyncSettingsPage({ + loader = getDeviceSyncStatus, + enabledSaver = setDeviceSyncEnabled, + syncStarter = syncDeviceDataNow, +}: { + loader?: () => Promise; + enabledSaver?: (enabled: boolean) => Promise; + syncStarter?: () => Promise; +}) { + const t = useT(); + const [status, setStatus] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + let active = true; + void loader().then((next) => { + if (active) setStatus(next); + }).catch((error) => { + if (active) { + setStatus({ + transport: "paired-devices", + state: "error", + enabled: false, + available: false, + last_success_at: null, + message: String(error), + imported: null, + }); + } + }); + return () => { + active = false; + }; + }, [loader]); + + async function saveEnabled(enabled: boolean) { + setSaving(true); + try { + setStatus(await enabledSaver(enabled)); + } catch (error) { + setStatus((current) => ({ + transport: current?.transport ?? "paired-devices", + state: "error", + enabled: current?.enabled ?? false, + available: current?.available ?? false, + last_success_at: current?.last_success_at ?? null, + message: String(error), + imported: current?.imported ?? null, + })); + } finally { + setSaving(false); + } + } + + async function startSync() { + setStatus((current) => current ? { ...current, state: "syncing" } : current); + try { + setStatus(await syncStarter()); + } catch (error) { + setStatus((current) => current ? { ...current, state: "error", message: String(error) } : current); + } + } + + return ( + + void saveEnabled(checked)} + /> + + + + {t("settings.syncScope")} +

{t("settings.syncScopeHint")}

+
+ ); +} + +export function DeveloperSettingsPage({ + loader = getPluginDeveloperStatus, + modeSaver = setPluginDeveloperMode, + reloader = reloadDevelopmentPlugins, + devtoolsOpener = openDevtools, + diagnosticsExporter = exportRedactedDiagnostics, +}: { + loader?: () => Promise; + modeSaver?: (enabled: boolean) => Promise; + reloader?: () => Promise; + devtoolsOpener?: () => Promise; + diagnosticsExporter?: () => Promise; +}) { + const t = useT(); + const [status, setStatus] = useState(null); + const [saving, setSaving] = useState(false); + const [reloading, setReloading] = useState(false); + const [error, setError] = useState(null); + const [diagnosticsExporting, setDiagnosticsExporting] = useState(false); + const [diagnosticsMessage, setDiagnosticsMessage] = useState(null); + + useEffect(() => { + let active = true; + let unsubscribe = () => {}; + const refresh = () => { + void loader().then((next) => { + if (active) { + setStatus(next); + setError(null); + } + }).catch((cause) => { + if (active) setError(t("settings.developerLoadFailed", { error: String(cause) })); + }); + }; + refresh(); + void onPluginsChanged(refresh).then((stop) => { + if (active) unsubscribe = stop; + else stop(); + }); + return () => { + active = false; + unsubscribe(); + }; + }, [loader, t]); + + async function saveMode(enabled: boolean) { + setSaving(true); + setError(null); + try { + setStatus(await modeSaver(enabled)); + } catch (cause) { + setError(t("settings.developerSaveFailed", { error: String(cause) })); + } finally { + setSaving(false); + } + } + + async function reload() { + setReloading(true); + setError(null); + try { + setStatus(await reloader()); + } catch (cause) { + setError(t("settings.developerReloadFailed", { error: String(cause) })); + } finally { + setReloading(false); + } + } + + async function showDevtools() { + setError(null); + try { + await devtoolsOpener(); + } catch (cause) { + setError(t("settings.developerDevtoolsFailed", { error: String(cause) })); + } + } + + async function exportDiagnostics() { + setDiagnosticsExporting(true); + setDiagnosticsMessage(null); + setError(null); + try { + const result = await diagnosticsExporter(); + if (result === "saved") setDiagnosticsMessage(t("settings.diagnosticsExported")); + else if (result === "unsupported") setError(t("settings.diagnosticsUnsupported")); + } catch (cause) { + setError(t("settings.diagnosticsExportFailed", { error: String(cause) })); + } finally { + setDiagnosticsExporting(false); + } + } + + const statusText = !status + ? t("settings.pluginHotReloadLoading") + : !status.enabled + ? t("settings.pluginHotReloadOff") + : !status.watching + ? t("settings.pluginHotReloadUnavailable") + : t("settings.pluginHotReloadWatching", { path: status.plugins_dir }); + const reloadRecord = status?.last_reload; + const reloadDetail = reloadRecord?.success + ? t("settings.pluginHotReloadLastSuccess", { + plugins: reloadRecord.plugins.length ? reloadRecord.plugins.join(", ") : t("settings.allInstalledPlugins"), + time: new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(reloadRecord.at), + }) + : reloadRecord?.error + ? t("settings.pluginHotReloadLastError", { error: reloadRecord.error }) + : null; + + return ( + + + void saveMode(checked)} + aria-label={t("settings.developerMode")} + /> + + {t("settings.pluginDevelopment")} + {statusText}{reloadDetail && {reloadDetail}}} + > + + + + + + {t("settings.supportDiagnostics")} + {diagnosticsMessage ?? t("settings.exportDiagnosticsHint")}} + > + + + {error &&

{error}

} +
+ ); +} + +export function BrowserPermissionsSettingsPage() { + const [origins, setOrigins] = useState([]); + + useEffect(() => { + let active = true; + void browserPermissions().then((next) => { + if (active) setOrigins(next); + }); + return () => { + active = false; + }; + }, []); + + return ( + + } + label="Default browser adapter" + hint="Ordinary requests use C2 Browser. Explicit Chrome, existing-tab, or existing-login requests use Chrome." + > + Experimental + + Permanent website access + {origins.length === 0 ? ( +

No origins have permanent access.

+ ) : origins.map((origin) => ( + + + + ))} +
+ ); +} diff --git a/apps/desktop/src/settings/PersonalSettings.tsx b/apps/desktop/src/settings/PersonalSettings.tsx new file mode 100644 index 00000000..3ef9a0ad --- /dev/null +++ b/apps/desktop/src/settings/PersonalSettings.tsx @@ -0,0 +1,352 @@ +import { useEffect, useMemo, useState } from "react"; +import { Download, RotateCcw } from "@/components/ui/icons"; + +import { + checkForAppUpdates, + getAppUpdateStatus, + importSessionFiles, + type AppUpdateStatus, + type KeymapEntry, + type SessionImportResult, +} from "../bridge"; +import { formatCombo, MOD_LABEL } from "../keys"; +import { useLanguage, useT, type LanguagePreference } from "../i18n"; +import { en as EN_STRINGS, LOCALES, type StringKey } from "../i18n/strings"; +import { setTerminalSettings, useTerminalSettings } from "../terminal/settings"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Spinner } from "@/components/ui/spinner"; +import { cn } from "@/lib/utils"; +import { GroupHeading, Page, Row } from "./SettingsPrimitives"; + +const GROUPS: { labelKey: StringKey; actions: string[] }[] = [ + { + labelKey: "settings.groupPrompt", + actions: ["run", "cancel", "open_skill_picker", "focus_editor", "toggle_doc_mode"], + }, + { + labelKey: "settings.groupSessions", + actions: ["new_session", "prev_session", "next_session"], + }, + { + labelKey: "settings.groupPanels", + actions: ["toggle_terminal", "toggle_browser", "toggle_git", "close_panel"], + }, + { labelKey: "settings.groupGit", actions: ["refresh_git", "open_source_control"] }, + { + labelKey: "settings.groupOpen", + actions: [ + "open_command_palette", + "open_market", + "open_files", + "search_workspace", + "open_issues", + "open_usage", + "open_settings", + ], + }, + { labelKey: "settings.groupModes", actions: ["cycle_permission_mode"] }, +]; + +export function GeneralSettingsPage({ + statusLoader = getAppUpdateStatus, + checkStarter = checkForAppUpdates, +}: { + statusLoader?: () => Promise; + checkStarter?: () => Promise; +}) { + const t = useT(); + const { preference: language, setPreference: setLanguage } = useLanguage(); + const terminal = useTerminalSettings(); + const [update, setUpdate] = useState(null); + + useEffect(() => { + let active = true; + void statusLoader() + .then((status) => { + if (active) setUpdate(status); + }) + .catch((error) => { + if (active) setUpdate({ state: "unavailable", message: String(error) }); + }); + return () => { + active = false; + }; + }, [statusLoader]); + + useEffect(() => { + if (update?.state !== "checking") return; + let active = true; + const timer = window.setInterval(() => { + void statusLoader() + .then((status) => { + if (active) setUpdate(status); + }) + .catch((error) => { + if (active) setUpdate({ state: "unavailable", message: String(error) }); + }); + }, 1000); + return () => { + active = false; + window.clearInterval(timer); + }; + }, [statusLoader, update?.state]); + + const updateHint = (() => { + switch (update?.state) { + case "ready": + return t("settings.updateReady", { + version: update.currentVersion ?? t("settings.updateUnknownVersion"), + }); + case "checking": + return t("settings.updateChecking"); + case "not-configured": + return t("settings.updateNotConfigured"); + case "unsupported": + return t("settings.updateUnsupported"); + case "unavailable": + return t("settings.updateUnavailable"); + default: + return t("settings.updateLoading"); + } + })(); + + async function startUpdateCheck() { + setUpdate({ state: "checking", currentVersion: update?.currentVersion }); + try { + setUpdate(await checkStarter()); + } catch (error) { + setUpdate({ state: "unavailable", message: String(error) }); + } + } + + return ( + + + + + + {t("settings.softwareUpdate")} + + + + + {t("settings.terminal")} + + setTerminalSettings({ fontFamily: event.target.value })} + className="w-44 text-hint" + /> + + + setTerminalSettings({ fontSize: Number(event.target.value) })} + className="w-44 text-hint" + /> + + + setTerminalSettings({ scrollback: Number(event.target.value) })} + className="w-44 text-hint" + /> + + + ); +} + +export function ImportSettingsPage({ + projectPath, + importer = importSessionFiles, + onImported = async () => {}, + onOpenSession = () => {}, +}: { + projectPath: string; + importer?: (fallbackCwd: string) => Promise; + onImported?: () => void | Promise; + onOpenSession?: (sessionId: string) => void; +}) { + const t = useT(); + const [importing, setImporting] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + + async function startImport() { + setImporting(true); + setError(null); + try { + const next = await importer(projectPath); + if (!next) return; + setResult(next); + if (next.imported > 0) await onImported(); + } catch (cause) { + setError(t("settings.importFailed", { error: String(cause) })); + } finally { + setImporting(false); + } + } + + return ( + + {t("settings.importFromFiles")} + } + label={t("settings.importSessions")} + hint={t("settings.importSessionsHint")} + > + + + {error &&

{error}

} + {result && ( +
0 ? "alert" : "status"} + aria-live="polite" + className="session-import-result" + > +
+

+ {t("settings.importResult", { + imported: result.imported, + skipped: result.skipped, + failed: result.failed, + })} +

+

+ {t("settings.importedMessages", { count: result.messages })} +

+ {result.errors.slice(0, 3).map((item) => ( +

+ {item.path}: {item.message} +

+ ))} +
+ {result.sessions[0] && ( + + )} +
+ )} +
+ ); +} + +export function KeybindingsSettingsPage({ + bindings, + capturing, + onCapture, + onReset, +}: { + bindings: KeymapEntry[]; + capturing: string | null; + onCapture: (action: string) => void; + onReset?: (action: string) => void; +}) { + const t = useT(); + const byAction = useMemo(() => new Map(bindings.map((binding) => [binding[0], binding])), [bindings]); + const conflicts = useMemo(() => { + const seen = new Map(); + for (const [, key] of bindings) seen.set(key, (seen.get(key) ?? 0) + 1); + return new Set([...seen.entries()].filter(([, count]) => count > 1).map(([key]) => key)); + }, [bindings]); + const known = new Set(GROUPS.flatMap((group) => group.actions)); + const groups = [ + ...GROUPS.map((group) => ({ title: t(group.labelKey), actions: group.actions })), + { + title: t("settings.groupOther"), + actions: bindings.map((binding) => binding[0]).filter((action) => !known.has(action)), + }, + ].filter((group) => group.actions.length > 0); + + function renderRow(action: string) { + const entry = byAction.get(action); + if (!entry) return null; + const [, key, coreLabel] = entry; + const labelKey = `action.${action}` as StringKey; + const label = labelKey in EN_STRINGS ? t(labelKey) : coreLabel; + return ( + + {conflicts.has(key) && capturing !== action && ( + + {t("settings.conflict")} + + )} + + {onReset && ( + + )} + + ); + } + + return ( + + {groups.map((group) => ( +
+ {group.title} +
{group.actions.map(renderRow)}
+
+ ))} +
+ ); +} diff --git a/apps/desktop/src/settings/ProjectSettings.tsx b/apps/desktop/src/settings/ProjectSettings.tsx new file mode 100644 index 00000000..3f0bc389 --- /dev/null +++ b/apps/desktop/src/settings/ProjectSettings.tsx @@ -0,0 +1,509 @@ +import { useEffect, useMemo, useState } from "react"; +import { Copy, FolderOpen, ImagePlus, Plus, RotateCcw, Trash2 } from "@/components/ui/icons"; + +import { + confirmNative, + getProjectScheduling, + openNativePath, + pickProjectIcon, + setProjectScheduling, + type Project, + type ProjectWorktreeMode, + type ProviderInfo, +} from "../bridge"; +import { useT } from "../i18n"; +import type { StringKey } from "../i18n/strings"; +import { ProjectIcon } from "../projects/ProjectIcon"; +import { ProviderIcon } from "../providers/ProviderIcon"; +import { ModelPicker } from "../session/Composer"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { GroupHeading, Page, ProjectRow } from "./SettingsPrimitives"; + +const REASONING_EFFORTS = ["minimal", "low", "medium", "high", "xhigh", "max", "ultra"] as const; + +export function ProjectSettingsPage({ + project, + providers, + onWorktreeMode, + onRename = async () => {}, + onIcon = async () => {}, + onAgentDefaults = async () => {}, + onRemove = async () => {}, + iconPicker = pickProjectIcon, + actionsCount = 0, + onAddAction = () => {}, + onModeSavingChange = () => {}, +}: { + project: Project | null; + providers: ProviderInfo[]; + onWorktreeMode: (path: string, mode: ProjectWorktreeMode | null) => Promise; + onRename?: (path: string, name: string) => Promise; + onIcon?: (path: string, source: string | null) => Promise; + onAgentDefaults?: ( + path: string, + provider: string | null, + model: string | null, + reasoningEffort: string | null, + ) => Promise; + onRemove?: (path: string) => Promise; + iconPicker?: () => Promise; + actionsCount?: number; + onAddAction?: () => void; + onModeSavingChange?: (saving: boolean) => void; +}) { + const t = useT(); + const providerNames = useMemo( + () => Object.fromEntries(providers.map((candidate) => [candidate.id, candidate.display_name])), + [providers], + ); + const [modeSaving, setModeSaving] = useState(false); + const [nameDraft, setNameDraft] = useState(project?.name ?? ""); + const [profileSaving, setProfileSaving] = useState(false); + const [iconSaving, setIconSaving] = useState(false); + const [agentSaving, setAgentSaving] = useState(false); + const [error, setError] = useState(null); + const [schedulingEnabled, setSchedulingEnabled] = useState(false); + + useEffect(() => { + setNameDraft(project?.name ?? ""); + setError(null); + }, [project?.path, project?.name]); + + useEffect(() => { + if (!project) return; + void getProjectScheduling(project.path).then(setSchedulingEnabled); + }, [project?.path]); + + async function saveWorktreeMode(path: string, mode: ProjectWorktreeMode | null) { + setModeSaving(true); + onModeSavingChange(true); + try { + await onWorktreeMode(path, mode); + } finally { + setModeSaving(false); + onModeSavingChange(false); + } + } + + async function saveName() { + if (!project) return; + const name = nameDraft.trim(); + if (!name) { + setError(t("settings.projectNameRequired")); + setNameDraft(project.name); + return; + } + if (name === project.name) return; + setProfileSaving(true); + setError(null); + try { + await onRename(project.path, name); + } catch (cause) { + setNameDraft(project.name); + setError(t("settings.projectSaveFailed", { error: String(cause) })); + } finally { + setProfileSaving(false); + } + } + + async function chooseIcon() { + if (!project) return; + const source = await iconPicker(); + if (!source) return; + setIconSaving(true); + setError(null); + try { + await onIcon(project.path, source); + } catch (cause) { + setError(t("settings.projectIconFailed", { error: String(cause) })); + } finally { + setIconSaving(false); + } + } + + async function clearIcon() { + if (!project) return; + setIconSaving(true); + setError(null); + try { + await onIcon(project.path, null); + } catch (cause) { + setError(t("settings.projectIconFailed", { error: String(cause) })); + } finally { + setIconSaving(false); + } + } + + async function saveAgentDefaults( + providerId: string | null, + modelId: string | null, + reasoningEffort: string | null, + ) { + if (!project) return; + setAgentSaving(true); + setError(null); + try { + await onAgentDefaults(project.path, providerId, modelId, reasoningEffort); + } catch (cause) { + setError(t("settings.projectSaveFailed", { error: String(cause) })); + } finally { + setAgentSaving(false); + } + } + + async function removeProject() { + if (!project) return; + if (!(await confirmNative(t("settings.removeProjectConfirm", { name: project.name })))) return; + setProfileSaving(true); + setError(null); + try { + await onRemove(project.path); + } catch (cause) { + setError(t("settings.projectSaveFailed", { error: String(cause) })); + } finally { + setProfileSaving(false); + } + } + + const projectDefaultProvider = project?.default_provider ?? null; + const projectDefaultModels = projectDefaultProvider + ? providers.find((candidate) => candidate.id === projectDefaultProvider)?.models ?? [] + : []; + return ( + + {project ? ( + <> + {t("settings.projectProfile")} + + setNameDraft(event.currentTarget.value)} + onBlur={() => void saveName()} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + if (event.key === "Escape") { + setNameDraft(project.name); + event.currentTarget.blur(); + } + }} + /> + + +
+ + {project.has_icon ? ( + <> +
+
+ {error ? ( +

{error}

+ ) : null} + + {t("settings.projectNewSessions")} + + + + +
+ {projectDefaultProvider && projectDefaultModels.length > 0 ? ( +
+ { + void saveAgentDefaults( + projectDefaultProvider, + model, + project.default_reasoning_effort ?? null, + ); + }} + configOptions={[]} + onConfigOption={() => {}} + hasSession={false} + /> + {project.default_model ? ( + + ) : null} +
+ ) : ( + + {t("settings.projectModelDefault")} + + )} + {projectDefaultProvider ? ( + + ) : null} +
+
+ + + + + { + const enabled = checked; + setSchedulingEnabled(enabled); + setError(null); + void setProjectScheduling(project.path, enabled).catch((error) => { + setSchedulingEnabled(!enabled); + setError(t("settings.projectSaveFailed", { error: String(error) })); + }); + }} + /> + + + {t("settings.projectCheckout")} + +
+ + {project.path} + + + +
+
+ + {t("settings.projectActions")} + + + + + {t("settings.projectDanger")} + + + + + ) : ( +

{t("settings.projectNone")}

+ )} +
+ ); +} diff --git a/apps/desktop/src/settings/ProviderSettings.tsx b/apps/desktop/src/settings/ProviderSettings.tsx new file mode 100644 index 00000000..2015db73 --- /dev/null +++ b/apps/desktop/src/settings/ProviderSettings.tsx @@ -0,0 +1,310 @@ +import { useEffect, useState } from "react"; +import { ChevronDown, Download, RefreshCw } from "@/components/ui/icons"; + +import { + installProvider, + setProviderEnabled, + upgradeProvider, + type ProviderInfo, +} from "../bridge"; +import { ProviderIcon } from "../providers/ProviderIcon"; +import { useT } from "../i18n"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; +import { Switch } from "@/components/ui/switch"; +import { cn } from "@/lib/utils"; +import { Page } from "./SettingsPrimitives"; + +const CAPABILITY_LABELS = { + image_generation: "Image generation", + computer_use: "Computer Use", + chrome_browser: "Browser Use", + codetwo_browser: "C2 Browser", + sites: "Sites", +} as const; + +type ProviderOperation = { + id: string; + action: "install" | "upgrade" | "enable" | "refresh"; +}; + +export function ProviderSettingsPage({ + providers, + reload, + installer = installProvider, + upgrader = upgradeProvider, + enabledSaver = setProviderEnabled, +}: { + providers: ProviderInfo[]; + reload?: () => void | Promise; + installer?: (provider: string) => Promise; + upgrader?: (provider: string) => Promise; + enabledSaver?: (provider: string, enabled: boolean) => Promise; +}) { + const t = useT(); + const [expanded, setExpanded] = useState>(() => new Set()); + const [operation, setOperation] = useState(null); + const [message, setMessage] = useState<{ id: string; text: string } | null>(null); + const [error, setError] = useState<{ id: string; text: string } | null>(null); + + useEffect(() => { + if (!reload) return; + let active = true; + setOperation({ id: "*", action: "refresh" }); + setError(null); + void (async () => { + try { + await reload(); + if (active) setMessage({ id: "*", text: t("settings.providerChecked") }); + } catch (cause) { + if (active) { + setError({ id: "*", text: t("settings.providerRefreshFailed", { error: String(cause) }) }); + } + } finally { + if (active) setOperation(null); + } + })(); + return () => { + active = false; + }; + }, [reload, t]); + + async function refresh() { + if (!reload || operation) return; + setOperation({ id: "*", action: "refresh" }); + setError(null); + try { + await reload(); + setMessage({ id: "*", text: t("settings.providerChecked") }); + } catch (cause) { + setError({ id: "*", text: t("settings.providerRefreshFailed", { error: String(cause) }) }); + } finally { + setOperation(null); + } + } + + async function runAction(providerId: string, action: "install" | "upgrade") { + if (operation) return; + const candidate = providers.find((item) => item.id === providerId); + if (!candidate) return; + setOperation({ id: providerId, action }); + setError(null); + setMessage(null); + try { + if (action === "install") await installer(providerId); + else await upgrader(providerId); + setMessage({ + id: providerId, + text: action === "install" + ? t("settings.providerInstalled", { provider: candidate.display_name }) + : t("settings.providerUpgraded", { provider: candidate.display_name }), + }); + await reload?.(); + } catch (cause) { + setError({ id: providerId, text: t("settings.providerActionFailed", { error: String(cause) }) }); + } finally { + setOperation(null); + } + } + + async function saveEnabled(providerId: string, enabled: boolean) { + if (operation) return; + const candidate = providers.find((item) => item.id === providerId); + if (!candidate) return; + setOperation({ id: providerId, action: "enable" }); + setError(null); + setMessage(null); + try { + await enabledSaver(providerId, enabled); + setMessage({ + id: providerId, + text: enabled + ? t("settings.providerEnabledMessage", { provider: candidate.display_name }) + : t("settings.providerDisabledMessage", { provider: candidate.display_name }), + }); + await reload?.(); + } catch (cause) { + setError({ id: providerId, text: t("settings.providerActionFailed", { error: String(cause) }) }); + } finally { + setOperation(null); + } + } + + function toggle(providerId: string) { + setExpanded((current) => { + const next = new Set(current); + if (next.has(providerId)) next.delete(providerId); + else next.add(providerId); + return next; + }); + } + + return ( + +
+ {(operation?.action === "refresh" || message?.id === "*") && ( + + {operation?.action === "refresh" ? t("settings.providerChecking") : message?.text} + + )} + +
+ {error?.id === "*" &&

{error.text}

} +
+ {providers.map((provider) => { + const enabled = provider.enabled !== false; + const management = provider.management ?? { + installed: provider.available, + version: null, + latest_version: null, + update_available: null, + check_error: null, + install_supported: false, + upgrade_supported: false, + launch_mode: provider.available ? "installed" as const : "unavailable" as const, + }; + const isExpanded = expanded.has(provider.id); + const activeOperation = operation?.id === provider.id ? operation.action : null; + const status = !enabled + ? t("settings.providerDisabled") + : management.installed + ? management.version + ? t("settings.providerInstalledVersion", { version: management.version }) + : t("settings.installed") + : management.launch_mode === "on_demand" + ? t("settings.providerReadyOnDemand") + : t("settings.notInstalled"); + return ( +
+
+ + {!management.installed && management.install_supported && ( + + )} + {management.installed && management.upgrade_supported && management.update_available === true && ( + + )} + void saveEnabled(provider.id, checked)} + /> +
+ {(message?.id === provider.id || error?.id === provider.id) && ( +

+ {error?.id === provider.id ? error.text : message?.text} +

+ )} + {isExpanded && ( +
+
+ {provider.id} + + {management.launch_mode === "installed" + ? t("settings.providerLocalRuntime") + : management.launch_mode === "on_demand" + ? t("settings.providerOnDemandRuntime") + : t("settings.providerUnavailableRuntime")} + + {provider.needs_node && {t("settings.needsNode")}} +
+ {provider.capabilities.filter((capability) => capability.state !== "unavailable").map((capability) => ( +
+
+
+ {CAPABILITY_LABELS[capability.id]} + {capability.experimental && Experimental} + {capability.version && {capability.version}} +
+ {capability.reason &&

{capability.reason}

} + {capability.fix &&

{capability.fix}

} +
+ + + {capability.state} + +
+ ))} +
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/apps/desktop/src/settings/SettingsPage.tsx b/apps/desktop/src/settings/SettingsPage.tsx index 451c823e..c2eb4a7a 100644 --- a/apps/desktop/src/settings/SettingsPage.tsx +++ b/apps/desktop/src/settings/SettingsPage.tsx @@ -1,126 +1,77 @@ -import { - useEffect, - useMemo, - useRef, - useState, - type CSSProperties, - type ReactNode, -} from "react"; +import { useEffect, useMemo, useState, type CSSProperties } from "react"; import { ArrowLeft, BrainCircuit, - Bug, ChartNoAxesColumn, - ChevronDown, - Copy, Download, Folder, - FolderOpen, GitBranch, Globe, - ImagePlus, Keyboard, - LoaderCircle, - MessageSquare, MousePointer2, Package, Palette, - Plus, PawPrint, RefreshCw, RotateCcw, ScanText, SlidersHorizontal, - Trash2, UserRound, Wrench, } from "@/components/ui/icons"; import { - browserPermissions, - browserRevokePermission, checkForAppUpdates, - getBrowserUseSettings, - getComputerUseSettings, - getAppshotSettings, confirmNative, discardOrphanWorktree, discardSessionWorktree, - exportRedactedDiagnostics, getAppUpdateStatus, - getDeviceSyncStatus, - getPluginDeveloperStatus, getWorktreeSettings, - importSessionFiles, listProjectWorktrees, - selectComputerUseBackend, - selectBrowserUseBackend, - setAgentBrowserAccess, - openAppshotPrivacySettings, - requestAppshotPermissions, - takeAppshot, - updateAppshotSettings, updateWorktreeSettings, type AppshotSettings, type BrowserUseSettings, type ComputerUseSettings, type AppUpdateStatus, + type DeviceSyncStatus, + type DiagnosticsExportResult, type KeymapEntry, type Project, type ProjectWorktreeMode, type ProviderInfo, - type WorktreeEntryKind, - type WorktreeSettings, - type WorktreeStatusEntry, type SessionImportResult, - getProjectScheduling, - installProvider, - openNativePath, - openDevtools, - onPluginsChanged, - pickProjectIcon, - setProviderEnabled, - setProjectScheduling, - upgradeProvider, - setDeviceSyncEnabled, - setPluginDeveloperMode, - syncDeviceDataNow, - reloadDevelopmentPlugins, - type DeviceSyncStatus, - type DiagnosticsExportResult, + type WorktreeSettings, type PluginDeveloperStatus, } from "../bridge"; -import { formatCombo, MOD_LABEL } from "../keys"; -import { useLanguage, useT, type LanguagePreference } from "../i18n"; -import { en as EN_STRINGS, LOCALES, type StringKey } from "../i18n/strings"; +import { useLanguage, useT } from "../i18n"; +import type { StringKey } from "../i18n/strings"; import { resetVisualAppearanceSettings } from "../appearance"; import { useTheme } from "../theme"; -import { setTerminalSettings, useTerminalSettings } from "../terminal/settings"; -import { ProviderIcon } from "../providers/ProviderIcon"; import { UsagePanel } from "../usage/Usage"; import { MemorySettingsPage } from "./MemorySettings"; import { AppearanceSettings } from "./AppearanceSettings"; -import { ProjectIcon } from "../projects/ProjectIcon"; -import { ModelPicker } from "../session/Composer"; +import { AppshotsSettingsPage } from "./AppshotsSettings"; import { PetSettings } from "./PetSettings"; +import { + GeneralSettingsPage, + ImportSettingsPage, + KeybindingsSettingsPage, +} from "./PersonalSettings"; import { ProfileSettings } from "./ProfileSettings"; +import { ProjectSettingsPage } from "./ProjectSettings"; +import { ProviderSettingsPage } from "./ProviderSettings"; +import { WorktreeSettingsPage } from "./WorktreeSettings"; +import { Page } from "./SettingsPrimitives"; import { - worktreeBranchDisplay, - worktreeDiscardRoute, - worktreeStatusBadges, - type WorktreeStatusBadge, -} from "./worktrees"; -import { SettingToggle } from "@/components/business/setting-toggle"; -import { SettingRow } from "@/components/business/setting-row"; -import { SettingsPanel } from "@/components/business/settings-panel"; -import { NavigationRow } from "@/components/business/navigation-row"; + BrowserPermissionsSettingsPage, + BrowserUseSettingsPage, + ComputerUseSettingsPage, + DeveloperSettingsPage, + DeviceSyncSettingsPage, +} from "./OperationalSettings"; import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { Input } from "@/components/ui/input"; +import { NavigationRow } from "@/components/business/navigation-row"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { Spinner } from "@/components/ui/spinner"; -import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; import { cn } from "@/lib/utils"; import "./settings-page.css"; @@ -192,132 +143,8 @@ const NAV_GROUPS: { }, ]; -const WORKTREE_KIND_LABELS: Record = { - session: "worktree.kindSession", - orphan: "worktree.kindOrphan", - stale: "worktree.kindStale", -}; - -const WORKTREE_BADGE_LABELS: Record = { - archived: "worktree.badgeArchived", - discarded: "worktree.badgeDiscarded", - checkoutMissing: "worktree.badgeCheckoutMissing", -}; - -type ProjectWorktreeState = { - entries: WorktreeStatusEntry[]; - error: string | null; -}; - const EMPTY_PROJECTS: Project[] = []; -const CAPABILITY_LABELS = { - image_generation: "Image generation", - computer_use: "Computer Use", - chrome_browser: "Browser Use", - codetwo_browser: "C2 Browser", - sites: "Sites", -} as const; - -const PROJECT_REASONING_EFFORTS = ["minimal", "low", "medium", "high", "xhigh", "max", "ultra"] as const; -// Actions grouped by what they touch — a flat list of twenty-two is hard to scan. Anything not -// listed still shows under "Other", so a new binding is never hidden. -const GROUPS: { title: string; labelKey: StringKey; actions: string[] }[] = [ - { - title: "Prompt", - labelKey: "settings.groupPrompt", - actions: ["run", "cancel", "open_skill_picker", "focus_editor", "toggle_doc_mode"], - }, - { title: "Sessions", labelKey: "settings.groupSessions", actions: ["new_session", "prev_session", "next_session"] }, - { - title: "Panels", - labelKey: "settings.groupPanels", - actions: ["toggle_terminal", "toggle_browser", "toggle_git", "close_panel"], - }, - { title: "Git", labelKey: "settings.groupGit", actions: ["refresh_git", "open_source_control"] }, - { - title: "Open", - labelKey: "settings.groupOpen", - actions: ["open_command_palette", "open_market", "open_files", "search_workspace", "open_issues", "open_usage", "open_settings"], - }, - { title: "Modes", labelKey: "settings.groupModes", actions: ["cycle_permission_mode"] }, -]; - -function Row({ - icon, - label, - hint, - compact, - className, - controlClassName, - children, -}: { - icon?: ReactNode; - label: string; - hint?: ReactNode; - /** Dense lists (keybindings) — same anatomy, tighter rhythm. */ - compact?: boolean; - className?: string; - controlClassName?: string; - children: ReactNode; -}) { - return ( - - {children} - - ); -} - -/** Project settings share one trailing control lane so fields and actions stay on the same grid. */ -function ProjectRow(props: Parameters[0]) { - return ( - - {props.children} - - ); -} - -/** Muted uppercase divider between row groups on one page. */ -function GroupHeading({ children }: { children: ReactNode }) { - return ( -

- {children} -

- ); -} - -/** - * The frame every tab renders through: same title block, same description slot, same measure. The - * description is a slot rather than an afterthought so the first row starts at the same height on - * every page — General used to skip it and sat one line higher than the rest. - */ -function Page({ title, description, children }: { title: string; description?: string; children: ReactNode }) { - return ( -
-

{title}

- {description && ( -

{description}

- )} - {children} -
- ); -} - /** * Settings as a full-window page: its own nav rail on the left (General, Memory, Keybindings, * Providers, Usage) @@ -342,11 +169,11 @@ export function SettingsPage({ onProjectIcon = async () => {}, onProjectAgentDefaults = async () => {}, onProjectRemove = async () => {}, - projectIconPicker = pickProjectIcon, + projectIconPicker, projectActionsCount = 0, onAddProjectAction = () => {}, onOpenSession = () => {}, - sessionImporter = importSessionFiles, + sessionImporter, onSessionsImported = async () => {}, worktreeLister = listProjectWorktrees, worktreeSettingsLoader = getWorktreeSettings, @@ -361,27 +188,27 @@ export function SettingsPage({ onClose, updateStatusLoader = getAppUpdateStatus, updateCheckStarter = checkForAppUpdates, - computerUseSettingsLoader = getComputerUseSettings, - computerUseSelectionSaver = selectComputerUseBackend, - browserUseSettingsLoader = getBrowserUseSettings, - browserUseSelectionSaver = selectBrowserUseBackend, - browserUseAccessSaver = setAgentBrowserAccess, - appshotSettingsLoader = getAppshotSettings, - appshotSettingsSaver = updateAppshotSettings, - appshotPermissionRequester = requestAppshotPermissions, - appshotPrivacyOpener = openAppshotPrivacySettings, - appshotCapturer = takeAppshot, - providerInstaller = installProvider, - providerUpgrader = upgradeProvider, - providerEnabledSaver = setProviderEnabled, - deviceSyncStatusLoader = getDeviceSyncStatus, - deviceSyncEnabledSaver = setDeviceSyncEnabled, - deviceSyncStarter = syncDeviceDataNow, - pluginDeveloperStatusLoader = getPluginDeveloperStatus, - pluginDeveloperModeSaver = setPluginDeveloperMode, - pluginDeveloperReloader = reloadDevelopmentPlugins, - devtoolsOpener = openDevtools, - diagnosticsExporter = exportRedactedDiagnostics, + computerUseSettingsLoader, + computerUseSelectionSaver, + browserUseSettingsLoader, + browserUseSelectionSaver, + browserUseAccessSaver, + appshotSettingsLoader, + appshotSettingsSaver, + appshotPermissionRequester, + appshotPrivacyOpener, + appshotCapturer, + providerInstaller, + providerUpgrader, + providerEnabledSaver, + deviceSyncStatusLoader, + deviceSyncEnabledSaver, + deviceSyncStarter, + pluginDeveloperStatusLoader, + pluginDeveloperModeSaver, + pluginDeveloperReloader, + devtoolsOpener, + diagnosticsExporter, }: { /** Matches the persisted width of the main session rail. */ sidebarWidth?: number; @@ -453,43 +280,13 @@ export function SettingsPage({ }) { const t = useT(); const { preference: theme, setPreference: setTheme } = useTheme(); - const { preference: language, setPreference: setLanguage } = useLanguage(); - const term = useTerminalSettings(); + const { setPreference: setLanguage } = useLanguage(); const providerNames = useMemo( () => Object.fromEntries(providers.map((candidate) => [candidate.id, candidate.display_name])), [providers], ); const [tab, setTab] = useState(initialTab); - const [appUpdate, setAppUpdate] = useState(null); - const [sessionImporting, setSessionImporting] = useState(false); - const [sessionImportResult, setSessionImportResult] = useState(null); - const [sessionImportError, setSessionImportError] = useState(null); - const [computerUseSettings, setComputerUseSettings] = useState(null); - const [computerUseSaving, setComputerUseSaving] = useState(null); - const [computerUseError, setComputerUseError] = useState(null); - const [browserUseSettings, setBrowserUseSettings] = useState(null); - const [browserUseSaving, setBrowserUseSaving] = useState(null); - const [browserUseError, setBrowserUseError] = useState(null); - const [appshotSettings, setAppshotSettings] = useState(null); - const [appshotSaving, setAppshotSaving] = useState(false); - const [appshotCapturing, setAppshotCapturing] = useState(false); - const [appshotError, setAppshotError] = useState(null); - const [expandedProviders, setExpandedProviders] = useState>(() => new Set()); - const [providerOperation, setProviderOperation] = useState<{ - id: string; - action: "install" | "upgrade" | "enable" | "refresh"; - } | null>(null); - const [providerMessage, setProviderMessage] = useState<{ id: string; text: string } | null>(null); - const [providerError, setProviderError] = useState<{ id: string; text: string } | null>(null); - const [deviceSync, setDeviceSync] = useState(null); - const [deviceSyncSaving, setDeviceSyncSaving] = useState(false); - const [pluginDevelopment, setPluginDevelopment] = useState(null); - const [pluginDevelopmentSaving, setPluginDevelopmentSaving] = useState(false); - const [pluginDevelopmentReloading, setPluginDevelopmentReloading] = useState(false); - const [pluginDevelopmentError, setPluginDevelopmentError] = useState(null); - const [diagnosticsExporting, setDiagnosticsExporting] = useState(false); - const [diagnosticsMessage, setDiagnosticsMessage] = useState(null); - const [diagnosticsError, setDiagnosticsError] = useState(null); + const [projectNavigationLocked, setProjectNavigationLocked] = useState(false); useEffect(() => setTab(initialTab), [initialTab]); useEffect(() => { if (!memoryEnabled) { @@ -502,461 +299,6 @@ export function SettingsPage({ } }, [deviceSyncEnabled]); - const startSessionImport = async () => { - setSessionImporting(true); - setSessionImportError(null); - try { - const result = await sessionImporter(projectPath); - if (!result) return; - setSessionImportResult(result); - if (result.imported > 0) await onSessionsImported(); - } catch (error: unknown) { - setSessionImportError(t("settings.importFailed", { error: String(error) })); - } finally { - setSessionImporting(false); - } - }; - useEffect(() => { - if (tab !== "providers" || !onReloadProviders) return; - let active = true; - setProviderOperation({ id: "*", action: "refresh" }); - setProviderError(null); - void (async () => { - try { - await onReloadProviders(); - if (active) setProviderMessage({ id: "*", text: t("settings.providerChecked") }); - } catch (error: unknown) { - if (active) { - setProviderError({ - id: "*", - text: t("settings.providerRefreshFailed", { error: String(error) }), - }); - } - } finally { - if (active) setProviderOperation(null); - } - })(); - return () => { - active = false; - }; - }, [tab, onReloadProviders, t]); - useEffect(() => { - if (tab !== "general") return; - let active = true; - void updateStatusLoader() - .then((status) => { - if (active) setAppUpdate(status); - }) - .catch((error) => { - if (active) setAppUpdate({ state: "unavailable", message: String(error) }); - }); - return () => { - active = false; - }; - }, [tab, updateStatusLoader]); - useEffect(() => { - if (tab !== "general" || appUpdate?.state !== "checking") return; - let active = true; - const timer = window.setInterval(() => { - void updateStatusLoader() - .then((status) => { - if (active) setAppUpdate(status); - }) - .catch((error) => { - if (active) setAppUpdate({ state: "unavailable", message: String(error) }); - }); - }, 1000); - return () => { - active = false; - window.clearInterval(timer); - }; - }, [tab, appUpdate?.state, updateStatusLoader]); - useEffect(() => { - if (tab !== "computer-use") return; - let active = true; - setComputerUseError(null); - void computerUseSettingsLoader() - .then((settings) => { - if (active) setComputerUseSettings(settings); - }) - .catch((error) => { - if (active) setComputerUseError(t("settings.computerUseLoadFailed", { error: String(error) })); - }); - return () => { - active = false; - }; - }, [tab, computerUseSettingsLoader, t]); - useEffect(() => { - if (tab !== "browser-use") return; - let active = true; - setBrowserUseError(null); - void browserUseSettingsLoader() - .then((settings) => { - if (active) setBrowserUseSettings(settings); - }) - .catch((error) => { - if (active) setBrowserUseError(t("settings.browserUseLoadFailed", { error: String(error) })); - }); - return () => { - active = false; - }; - }, [tab, browserUseSettingsLoader, t]); - useEffect(() => { - if (tab !== "appshots") return; - let active = true; - setAppshotError(null); - void appshotSettingsLoader() - .then((settings) => { - if (active) setAppshotSettings(settings); - }) - .catch((error) => { - if (active) setAppshotError(t("settings.appshotsLoadFailed", { error: String(error) })); - }); - return () => { - active = false; - }; - }, [tab, appshotSettingsLoader, t]); - useEffect(() => { - if ( - tab !== "appshots" - || !appshotSettings?.available - || (appshotSettings.screen_recording && appshotSettings.accessibility) - ) return; - let active = true; - const timer = window.setInterval(() => { - void appshotSettingsLoader().then((settings) => { - if (active) setAppshotSettings(settings); - }).catch(() => {}); - }, 1000); - return () => { - active = false; - window.clearInterval(timer); - }; - }, [ - tab, - appshotSettings?.available, - appshotSettings?.screen_recording, - appshotSettings?.accessibility, - appshotSettingsLoader, - ]); - useEffect(() => { - if (tab !== "sync") return; - let active = true; - void deviceSyncStatusLoader() - .then((status) => { - if (active) setDeviceSync(status); - }) - .catch((error) => { - if (active) { - setDeviceSync({ - transport: "paired-devices", - state: "error", - enabled: false, - available: false, - last_success_at: null, - message: String(error), - imported: null, - }); - } - }); - return () => { - active = false; - }; - }, [tab, deviceSyncStatusLoader]); - useEffect(() => { - if (tab !== "developer") return; - let active = true; - let unsubscribe = () => {}; - const refresh = () => { - void pluginDeveloperStatusLoader() - .then((status) => { - if (active) { - setPluginDevelopment(status); - setPluginDevelopmentError(null); - } - }) - .catch((error) => { - if (active) { - setPluginDevelopmentError(t("settings.developerLoadFailed", { error: String(error) })); - } - }); - }; - refresh(); - void onPluginsChanged(refresh).then((stop) => { - if (active) unsubscribe = stop; - else stop(); - }); - return () => { - active = false; - unsubscribe(); - }; - }, [tab, pluginDeveloperStatusLoader, t]); - const [projectModeSaving, setProjectModeSaving] = useState(false); - const [projectNameDraft, setProjectNameDraft] = useState(project?.name ?? ""); - const [projectProfileSaving, setProjectProfileSaving] = useState(false); - const [projectIconSaving, setProjectIconSaving] = useState(false); - const [projectAgentSaving, setProjectAgentSaving] = useState(false); - const [projectError, setProjectError] = useState(null); - useEffect(() => { - setProjectNameDraft(project?.name ?? ""); - setProjectError(null); - }, [project?.path, project?.name]); - // Scene `schedule` hooks are off by default per project (docs/scenes.md §Security). - const [schedulingEnabled, setSchedulingEnabled] = useState(false); - useEffect(() => { - if (!project) return; - void getProjectScheduling(project.path).then(setSchedulingEnabled); - }, [project?.path]); - const [browserOrigins, setBrowserOrigins] = useState([]); - const [worktreesByProject, setWorktreesByProject] = useState>({}); - const [worktreesLoading, setWorktreesLoading] = useState(false); - const [worktreeSettings, setWorktreeSettings] = useState(null); - const [worktreeSettingsSaving, setWorktreeSettingsSaving] = useState(false); - const [worktreeSettingsError, setWorktreeSettingsError] = useState(null); - const [worktreeRootDraft, setWorktreeRootDraft] = useState(""); - const [worktreeLimitDraft, setWorktreeLimitDraft] = useState("15"); - const worktreesRequestRef = useRef(0); - /** Path mid-discard; every Discard button is held while one runs. */ - const [discardingWorktree, setDiscardingWorktree] = useState(null); - - useEffect(() => { - if (tab === "browser") void browserPermissions().then(setBrowserOrigins); - }, [tab]); - - const loadWorktrees = async (projectList: Project[]) => { - const request = ++worktreesRequestRef.current; - setWorktreesLoading(true); - const results = await Promise.all(projectList.map(async (candidate) => { - try { - return [candidate.path, { entries: await worktreeLister(candidate.path), error: null }] as const; - } catch (error) { - return [candidate.path, { - entries: [], - error: t("worktree.manageFailed", { error: String(error) }), - }] as const; - } - })); - if (request !== worktreesRequestRef.current) return; - setWorktreesByProject(Object.fromEntries(results)); - setWorktreesLoading(false); - }; - - useEffect(() => { - if (tab !== "worktrees") { - worktreesRequestRef.current += 1; - setWorktreesLoading(false); - return; - } - void loadWorktrees(projects); - }, [tab, projects]); - - useEffect(() => { - if (tab !== "worktrees") return; - let active = true; - setWorktreeSettingsError(null); - void worktreeSettingsLoader() - .then((settings) => { - if (!active) return; - setWorktreeSettings(settings); - setWorktreeRootDraft(settings.root ?? ""); - setWorktreeLimitDraft(String(settings.auto_delete_limit)); - }) - .catch((error) => { - if (active) { - setWorktreeSettingsError(t("worktree.settingsLoadFailed", { error: String(error) })); - } - }); - return () => { - active = false; - }; - }, [tab, worktreeSettingsLoader, t]); - - const loadProjectWorktrees = async (path: string) => { - try { - const entries = await worktreeLister(path); - setWorktreesByProject((current) => ({ ...current, [path]: { entries, error: null } })); - } catch (error) { - setWorktreesByProject((current) => ({ - ...current, - [path]: { - entries: [], - error: t("worktree.manageFailed", { error: String(error) }), - }, - })); - } - }; - - const saveGlobalWorktreeSettings = async (patch: Partial) => { - if (!worktreeSettings) return false; - setWorktreeSettingsSaving(true); - setWorktreeSettingsError(null); - try { - const saved = await worktreeSettingsSaver({ ...worktreeSettings, ...patch }); - setWorktreeSettings(saved); - setWorktreeRootDraft(saved.root ?? ""); - setWorktreeLimitDraft(String(saved.auto_delete_limit)); - if ( - Object.prototype.hasOwnProperty.call(patch, "root") - || Object.prototype.hasOwnProperty.call(patch, "auto_delete") - ) { - await loadWorktrees(projects); - } - return true; - } catch (error) { - setWorktreeSettingsError(t("worktree.settingsSaveFailed", { error: String(error) })); - setWorktreeRootDraft(worktreeSettings.root ?? ""); - setWorktreeLimitDraft(String(worktreeSettings.auto_delete_limit)); - return false; - } finally { - setWorktreeSettingsSaving(false); - } - }; - - const commitWorktreeRoot = () => { - if (!worktreeSettings) return; - const root = worktreeRootDraft.trim() || undefined; - if (root === worktreeSettings.root) return; - void saveGlobalWorktreeSettings({ root }); - }; - - const commitWorktreeLimit = () => { - if (!worktreeSettings) return; - const parsed = Number.parseInt(worktreeLimitDraft, 10); - const limit = Number.isFinite(parsed) ? Math.min(1000, Math.max(1, parsed)) : worktreeSettings.auto_delete_limit; - setWorktreeLimitDraft(String(limit)); - if (limit !== worktreeSettings.auto_delete_limit) { - void saveGlobalWorktreeSettings({ auto_delete_limit: limit }); - } - }; - - const discardWorktree = async (projectPath: string, entry: WorktreeStatusEntry) => { - if (!(await worktreeDiscardConfirmer(t("worktree.discardConfirm", { path: entry.path })))) return; - setDiscardingWorktree(entry.path); - try { - const route = worktreeDiscardRoute(entry); - if (route.kind === "session") await sessionWorktreeDiscarder(route.session); - else await orphanWorktreeDiscarder(projectPath, route.worktreePath); - await loadProjectWorktrees(projectPath); - } catch (error) { - setWorktreesByProject((current) => ({ - ...current, - [projectPath]: { - entries: current[projectPath]?.entries ?? [], - error: t("worktree.discardFailed", { error: String(error) }), - }, - })); - } finally { - setDiscardingWorktree(null); - } - }; - - const saveProjectWorktreeMode = async ( - path: string, - mode: ProjectWorktreeMode | null, - ) => { - setProjectModeSaving(true); - try { - await onProjectWorktreeMode(path, mode); - } finally { - setProjectModeSaving(false); - } - }; - - const saveProjectName = async () => { - if (!project) return; - const name = projectNameDraft.trim(); - if (!name) { - setProjectError(t("settings.projectNameRequired")); - setProjectNameDraft(project.name); - return; - } - if (name === project.name) return; - setProjectProfileSaving(true); - setProjectError(null); - try { - await onProjectRename(project.path, name); - } catch (error) { - setProjectNameDraft(project.name); - setProjectError(t("settings.projectSaveFailed", { error: String(error) })); - } finally { - setProjectProfileSaving(false); - } - }; - - const chooseProjectIcon = async () => { - if (!project) return; - const source = await projectIconPicker(); - if (!source) return; - setProjectIconSaving(true); - setProjectError(null); - try { - await onProjectIcon(project.path, source); - } catch (error) { - setProjectError(t("settings.projectIconFailed", { error: String(error) })); - } finally { - setProjectIconSaving(false); - } - }; - - const clearProjectIcon = async () => { - if (!project) return; - setProjectIconSaving(true); - setProjectError(null); - try { - await onProjectIcon(project.path, null); - } catch (error) { - setProjectError(t("settings.projectIconFailed", { error: String(error) })); - } finally { - setProjectIconSaving(false); - } - }; - - const saveProjectAgentDefaults = async ( - providerId: string | null, - modelId: string | null, - reasoningEffort: string | null, - ) => { - if (!project) return; - setProjectAgentSaving(true); - setProjectError(null); - try { - await onProjectAgentDefaults(project.path, providerId, modelId, reasoningEffort); - } catch (error) { - setProjectError(t("settings.projectSaveFailed", { error: String(error) })); - } finally { - setProjectAgentSaving(false); - } - }; - - const removeCurrentProject = async () => { - if (!project) return; - if (!(await confirmNative(t("settings.removeProjectConfirm", { name: project.name })))) return; - setProjectProfileSaving(true); - setProjectError(null); - try { - await onProjectRemove(project.path); - } catch (error) { - setProjectError(t("settings.projectSaveFailed", { error: String(error) })); - } finally { - setProjectProfileSaving(false); - } - }; - - const byAction = useMemo(() => new Map(bindings.map((b) => [b[0], b])), [bindings]); - - // Which combos are bound more than once — a rebind can silently shadow another action. - const conflicts = useMemo(() => { - const seen = new Map(); - for (const [, key] of bindings) seen.set(key, (seen.get(key) ?? 0) + 1); - return new Set([...seen.entries()].filter(([, n]) => n > 1).map(([k]) => k)); - }, [bindings]); - - const known = new Set(GROUPS.flatMap((g) => g.actions)); - const groups = [ - ...GROUPS.map((g) => ({ title: t(g.labelKey), actions: g.actions })), - { title: t("settings.groupOther"), actions: bindings.map((b) => b[0]).filter((a) => !known.has(a)) }, - ].filter((g) => g.actions.length > 0); - // What "Restore defaults" means depends on where you're standing. const restore = () => { if (tab === "general") { @@ -968,382 +310,6 @@ export function SettingsPage({ } }; - const appUpdateHint = (() => { - switch (appUpdate?.state) { - case "ready": - return t("settings.updateReady", { version: appUpdate.currentVersion ?? t("settings.updateUnknownVersion") }); - case "checking": - return t("settings.updateChecking"); - case "not-configured": - return t("settings.updateNotConfigured"); - case "unsupported": - return t("settings.updateUnsupported"); - case "unavailable": - return t("settings.updateUnavailable"); - default: - return t("settings.updateLoading"); - } - })(); - - const startUpdateCheck = async () => { - setAppUpdate({ state: "checking", currentVersion: appUpdate?.currentVersion }); - try { - setAppUpdate(await updateCheckStarter()); - } catch (error) { - setAppUpdate({ state: "unavailable", message: String(error) }); - } - }; - - const saveDeviceSyncEnabled = async (enabled: boolean) => { - setDeviceSyncSaving(true); - try { - setDeviceSync(await deviceSyncEnabledSaver(enabled)); - } catch (error) { - setDeviceSync((current) => ({ - transport: current?.transport ?? "paired-devices", - state: "error", - enabled: current?.enabled ?? false, - available: current?.available ?? false, - last_success_at: current?.last_success_at ?? null, - message: String(error), - imported: current?.imported ?? null, - })); - } finally { - setDeviceSyncSaving(false); - } - }; - - const startDeviceSync = async () => { - setDeviceSync((current) => current ? { ...current, state: "syncing" } : current); - try { - setDeviceSync(await deviceSyncStarter()); - } catch (error) { - setDeviceSync((current) => current ? { ...current, state: "error", message: String(error) } : current); - } - }; - - const savePluginDeveloperMode = async (enabled: boolean) => { - setPluginDevelopmentSaving(true); - setPluginDevelopmentError(null); - try { - setPluginDevelopment(await pluginDeveloperModeSaver(enabled)); - } catch (error) { - setPluginDevelopmentError(t("settings.developerSaveFailed", { error: String(error) })); - } finally { - setPluginDevelopmentSaving(false); - } - }; - - const reloadPlugins = async () => { - setPluginDevelopmentReloading(true); - setPluginDevelopmentError(null); - try { - setPluginDevelopment(await pluginDeveloperReloader()); - } catch (error) { - setPluginDevelopmentError(t("settings.developerReloadFailed", { error: String(error) })); - } finally { - setPluginDevelopmentReloading(false); - } - }; - - const showWebviewDevtools = async () => { - setPluginDevelopmentError(null); - try { - await devtoolsOpener(); - } catch (error) { - setPluginDevelopmentError(t("settings.developerDevtoolsFailed", { error: String(error) })); - } - }; - - const exportDiagnostics = async () => { - setDiagnosticsExporting(true); - setDiagnosticsMessage(null); - setDiagnosticsError(null); - try { - const result = await diagnosticsExporter(); - if (result === "saved") { - setDiagnosticsMessage(t("settings.diagnosticsExported")); - } else if (result === "unsupported") { - setDiagnosticsError(t("settings.diagnosticsUnsupported")); - } - } catch (error) { - setDiagnosticsError(t("settings.diagnosticsExportFailed", { error: String(error) })); - } finally { - setDiagnosticsExporting(false); - } - }; - - const pluginDevelopmentStatus = (() => { - if (!pluginDevelopment) return t("settings.pluginHotReloadLoading"); - if (!pluginDevelopment.enabled) return t("settings.pluginHotReloadOff"); - if (!pluginDevelopment.watching) return t("settings.pluginHotReloadUnavailable"); - return t("settings.pluginHotReloadWatching", { path: pluginDevelopment.plugins_dir }); - })(); - - const pluginReloadRecord = pluginDevelopment?.last_reload; - const pluginReloadDetail = pluginReloadRecord?.success - ? t("settings.pluginHotReloadLastSuccess", { - plugins: pluginReloadRecord.plugins.length - ? pluginReloadRecord.plugins.join(", ") - : t("settings.allInstalledPlugins"), - time: new Intl.DateTimeFormat(undefined, { - dateStyle: "medium", - timeStyle: "short", - }).format(pluginReloadRecord.at), - }) - : pluginReloadRecord?.error - ? t("settings.pluginHotReloadLastError", { error: pluginReloadRecord.error }) - : null; - - const deviceSyncHint = (() => { - switch (deviceSync?.state) { - case "disabled": - return deviceSync.available ? t("settings.syncReady") : t("settings.syncUnavailable"); - case "ready": - return deviceSync.last_success_at - ? t("settings.syncLastSuccess", { time: new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(deviceSync.last_success_at) }) - : t("settings.syncReady"); - case "syncing": - return t("settings.syncing"); - case "signed-out": - return t("settings.syncSignedOut"); - case "restricted": - return t("settings.syncRestricted"); - case "unsupported": - return t("settings.syncUnsupported"); - case "unavailable": - return t("settings.syncUnavailable"); - case "error": - return deviceSync.message || t("settings.syncUnavailable"); - default: - return deviceSync?.available ? t("settings.syncReady") : t("settings.syncLoading"); - } - })(); - - const saveComputerUseSelection = async (backendId: string) => { - setComputerUseSaving(backendId); - setComputerUseError(null); - try { - setComputerUseSettings(await computerUseSelectionSaver(backendId)); - } catch (error) { - setComputerUseError(t("settings.computerUseLoadFailed", { error: String(error) })); - } finally { - setComputerUseSaving(null); - } - }; - - const saveBrowserUseSelection = async (backendId: string) => { - setBrowserUseSaving(backendId); - setBrowserUseError(null); - try { - setBrowserUseSettings(await browserUseSelectionSaver(backendId)); - } catch (error) { - setBrowserUseError(t("settings.browserUseLoadFailed", { error: String(error) })); - } finally { - setBrowserUseSaving(null); - } - }; - - const saveAgentBrowserAccess = async (enabled: boolean) => { - setBrowserUseSaving("access"); - setBrowserUseError(null); - try { - setBrowserUseSettings(await browserUseAccessSaver(enabled)); - } catch (error) { - setBrowserUseError(t("settings.browserUseLoadFailed", { error: String(error) })); - } finally { - setBrowserUseSaving(null); - } - }; - - const saveAppshotSettings = async ( - patch: Partial>, - ) => { - setAppshotSaving(true); - setAppshotError(null); - try { - setAppshotSettings(await appshotSettingsSaver(patch)); - } catch (error) { - setAppshotError(t("settings.appshotsSaveFailed", { error: String(error) })); - } finally { - setAppshotSaving(false); - } - }; - - const grantAppshotAccess = async (kind: "screen-recording" | "accessibility") => { - setAppshotSaving(true); - setAppshotError(null); - try { - setAppshotSettings(await appshotPermissionRequester(kind)); - } catch (error) { - setAppshotError(t("settings.appshotsPermissionFailed", { error: String(error) })); - } finally { - setAppshotSaving(false); - } - }; - - const captureAppshot = async () => { - setAppshotCapturing(true); - setAppshotError(null); - try { - await appshotCapturer(); - } catch (error) { - setAppshotError(t("settings.appshotsCaptureFailed", { error: String(error) })); - } finally { - setAppshotCapturing(false); - } - }; - - const refreshProviderStatus = async () => { - if (!onReloadProviders || providerOperation) return; - setProviderOperation({ id: "*", action: "refresh" }); - setProviderError(null); - try { - await onReloadProviders(); - setProviderMessage({ id: "*", text: t("settings.providerChecked") }); - } catch (error) { - setProviderError({ - id: "*", - text: t("settings.providerRefreshFailed", { error: String(error) }), - }); - } finally { - setProviderOperation(null); - } - }; - - const runProviderAction = async (providerId: string, action: "install" | "upgrade") => { - if (providerOperation) return; - const candidate = providers.find((item) => item.id === providerId); - if (!candidate) return; - setProviderOperation({ id: providerId, action }); - setProviderError(null); - setProviderMessage(null); - try { - if (action === "install") await providerInstaller(providerId); - else await providerUpgrader(providerId); - setProviderMessage({ - id: providerId, - text: action === "install" - ? t("settings.providerInstalled", { provider: candidate.display_name }) - : t("settings.providerUpgraded", { provider: candidate.display_name }), - }); - await onReloadProviders?.(); - } catch (error) { - setProviderError({ - id: providerId, - text: t("settings.providerActionFailed", { error: String(error) }), - }); - } finally { - setProviderOperation(null); - } - }; - - const saveProviderEnabled = async (providerId: string, enabled: boolean) => { - if (providerOperation) return; - const candidate = providers.find((item) => item.id === providerId); - if (!candidate) return; - setProviderOperation({ id: providerId, action: "enable" }); - setProviderError(null); - setProviderMessage(null); - try { - await providerEnabledSaver(providerId, enabled); - setProviderMessage({ - id: providerId, - text: enabled - ? t("settings.providerEnabledMessage", { provider: candidate.display_name }) - : t("settings.providerDisabledMessage", { provider: candidate.display_name }), - }); - await onReloadProviders?.(); - } catch (error) { - setProviderError({ - id: providerId, - text: t("settings.providerActionFailed", { error: String(error) }), - }); - } finally { - setProviderOperation(null); - } - }; - - const toggleProviderDetails = (providerId: string) => { - setExpandedProviders((current) => { - const next = new Set(current); - if (next.has(providerId)) next.delete(providerId); - else next.add(providerId); - return next; - }); - }; - - const computerUseSelection = computerUseSettings?.selections["*"] ?? "automatic"; - const computerUseSelectionLabel = computerUseSelection === "automatic" - ? t("settings.computerUseAutomatic") - : computerUseSelection === "disabled" - ? t("settings.computerUseDisabled") - : computerUseSettings?.backends.find((backend) => backend.id === computerUseSelection)?.display_name - ?? computerUseSelection; - const browserUseSelection = browserUseSettings?.selections["*"] ?? "automatic"; - const browserUseSelectionLabel = browserUseSelection === "automatic" - ? t("settings.browserUseAutomatic") - : browserUseSelection === "disabled" - ? t("settings.browserUseDisabled") - : browserUseSettings?.backends.find((backend) => backend.id === browserUseSelection)?.display_name - ?? browserUseSelection; - const appshotHotkeyLabel = appshotSettings?.hotkey === "both-command" - ? t("settings.appshotsHotkeyBothCommand") - : appshotSettings?.hotkey === "command-shift-2" - ? t("settings.appshotsHotkeyCommandShift2") - : t("settings.appshotsHotkeyCommandOption2"); - const appshotDestinationLabel = appshotSettings?.destination === "automatic" - ? t("settings.appshotsDestinationAutomatic") - : appshotSettings?.destination === "current" - ? t("settings.appshotsDestinationCurrent") - : t("settings.appshotsDestinationNew"); - const projectDefaultProvider = project?.default_provider ?? null; - const projectDefaultModels = projectDefaultProvider - ? providers.find((candidate) => candidate.id === projectDefaultProvider)?.models ?? [] - : []; - - const keyRow = (action: string) => { - const entry = byAction.get(action); - if (!entry) return null; - const [, key, coreLabel] = entry; - // The core ships English labels. Prefer a translation keyed by action id; fall back to what the - // core said so an action this build doesn't know about still reads as something. - const labelKey = `action.${action}` as StringKey; - const label = labelKey in EN_STRINGS ? t(labelKey) : coreLabel; - return ( - - {conflicts.has(key) && capturing !== action && ( - - {t("settings.conflict")} - - )} - - {onReset && ( - - )} - - ); - }; - return (
{/* ---- nav rail — same material as the app's rail, so settings still feels like this app */} @@ -1363,7 +329,7 @@ export function SettingsPage({ type="button" variant="ghost" size="row" - disabled={projectModeSaving} + disabled={projectNavigationLocked} onClick={onClose} aria-label={t("settings.back")} title={t("settings.back")} @@ -1382,11 +348,7 @@ export function SettingsPage({ .filter(({ id }) => deviceSyncEnabled || id !== "sync"); const headingId = `settings-nav-${group.id}`; return ( -
+

{tab === "general" && ( - - - - - - {t("settings.softwareUpdate")} - - - - - - {t("settings.terminal")} - - - setTerminalSettings({ fontFamily: e.target.value })} - className="h-8 w-44 text-hint" - /> - - - - setTerminalSettings({ fontSize: Number(e.target.value) })} - className="h-8 w-44 text-hint" - /> - - - - setTerminalSettings({ scrollback: Number(e.target.value) })} - className="h-8 w-44 text-hint" - /> - - + )} {tab === "import" && ( - - {t("settings.importFromFiles")} - } - label={t("settings.importSessions")} - hint={t("settings.importSessionsHint")} - > - - - - {sessionImportError && ( -

- {sessionImportError} -

- )} - - {sessionImportResult && ( -
0 ? "alert" : "status"} - aria-live="polite" - className="session-import-result" - > -
-

- {t("settings.importResult", { - imported: sessionImportResult.imported, - skipped: sessionImportResult.skipped, - failed: sessionImportResult.failed, - })} -

-

- {t("settings.importedMessages", { count: sessionImportResult.messages })} -

- {sessionImportResult.errors.slice(0, 3).map((error) => ( -

- {error.path}: {error.message} -

- ))} -
- {sessionImportResult.sessions[0] && ( - - )} -
- )} -
+ )} {tab === "appearance" && ( - + - + )} {tab === "profile" && } {tab === "pets" && ( - + - + )} {tab === "sync" && deviceSyncEnabled && ( - - void saveDeviceSyncEnabled(checked)} - /> - - - - - - {t("settings.syncScope")} -

- {t("settings.syncScopeHint")} -

-
+ )} {tab === "keybindings" && ( - - {groups.map((g) => ( -
- {g.title} -
{g.actions.map(keyRow)}
-
- ))} -
+ )} {tab === "project" && ( - - {project ? ( - <> - {t("settings.projectProfile")} - - setProjectNameDraft(event.currentTarget.value)} - onBlur={() => void saveProjectName()} - onKeyDown={(event) => { - if (event.key === "Enter") event.currentTarget.blur(); - if (event.key === "Escape") { - setProjectNameDraft(project.name); - event.currentTarget.blur(); - } - }} - /> - - -
- - {project.has_icon ? ( - <> -
-
- {projectError ? ( -

{projectError}

- ) : null} - - {t("settings.projectNewSessions")} - - - - -
- {projectDefaultProvider && projectDefaultModels.length > 0 ? ( -
- { - void saveProjectAgentDefaults( - projectDefaultProvider, - model, - project.default_reasoning_effort ?? null, - ); - }} - configOptions={[]} - onConfigOption={() => {}} - hasSession={false} - /> - {project.default_model ? ( - - ) : null} -
- ) : ( - - {t("settings.projectModelDefault")} - - )} - {projectDefaultProvider ? ( - - ) : null} -
-
- - - - { - const enabled = checked; - setSchedulingEnabled(enabled); - setProjectError(null); - void setProjectScheduling(project.path, enabled).catch((error) => { - setSchedulingEnabled(!enabled); - setProjectError(t("settings.projectSaveFailed", { error: String(error) })); - }); - }} - /> - - {t("settings.projectCheckout")} - -
- - {project.path} - - - -
-
- - {t("settings.projectActions")} - - - - - {t("settings.projectDanger")} - - - - - ) : ( -

{t("settings.projectNone")}

- )} -
+ )} {tab === "worktrees" && ( - -
- {worktreeSettings ? ( - <> - - setWorktreeRootDraft(event.target.value)} - onBlur={commitWorktreeRoot} - onKeyDown={(event) => { - if (event.key === "Enter") event.currentTarget.blur(); - }} - /> - - - { - void saveGlobalWorktreeSettings({ fetch_upstream }); - }} - /> - - - { - void saveGlobalWorktreeSettings({ auto_delete }); - }} - /> - - - setWorktreeLimitDraft(event.target.value)} - onBlur={commitWorktreeLimit} - onKeyDown={(event) => { - if (event.key === "Enter") event.currentTarget.blur(); - }} - /> - - - ) : ( -

- {t("worktree.settingsLoading")} -

- )} -
- {worktreeSettingsError ? ( -

- {worktreeSettingsError} -

- ) : null} - -
- -
- - {projects.length === 0 ? ( -

{t("worktree.manageNoProjects")}

- ) : worktreesLoading && Object.keys(worktreesByProject).length === 0 ? ( -

{t("worktree.manageLoading")}

- ) : ( - projects.map((candidate) => { - const state = worktreesByProject[candidate.path] ?? { entries: [], error: null }; - return ( -
-
- -
-

{candidate.name}

-

- {candidate.path} -

-
- - {t("worktree.count", { count: state.entries.length })} - -
- -
- {state.error ? ( -

- {state.error} -

- ) : state.entries.length === 0 ? ( -

- {t("worktree.manageEmpty")} -

- ) : ( - state.entries.map((entry) => { - const branch = worktreeBranchDisplay(entry.branch); - return ( - - - {t(WORKTREE_KIND_LABELS[entry.kind])} - {worktreeStatusBadges(entry).map((badge) => ( - - {t(WORKTREE_BADGE_LABELS[badge])} - - ))} - {branch && {branch}} - - - {entry.path} - - - )} - > - {entry.session_id ? ( - - ) : null} - - - ); - }) - )} -
-
- ); - }) - )} -
+ )} {tab === "memory" && memoryEnabled && ( @@ -2185,642 +502,50 @@ export function SettingsPage({ )} {tab === "computer-use" && ( - -

- {t("settings.computerUseNewSession")} -

- {computerUseError && ( -

- {computerUseError} -

- )} - {computerUseSettings?.errors.map((error) => ( -

- {error} -

- ))} - {!computerUseSettings ? ( -

{t("settings.computerUseLoading")}

- ) : ( - <> - - - - - {t("settings.computerUseBackends")} - {computerUseSettings.backends.map((backend) => ( - {backend.id}} - > - - - {backend.available - ? t("settings.computerUseAvailable") - : t("settings.computerUseUnavailable")} - - - ))} - - )} -
+ )} {tab === "appshots" && ( - -
- } - surface="card" - > - - -
- - {appshotError && ( -

- {appshotError} -

- )} - {!appshotSettings ? ( -

{t("settings.appshotsLoading")}

- ) : !appshotSettings.available ? ( -

- {appshotSettings.unavailable_reason ?? t("settings.appshotsUnavailable")} -

- ) : ( - <> - - - - - - - - - void saveAppshotSettings({ play_sound })} - /> - - {t("settings.appshotsPermissions")} - - {appshotSettings.screen_recording ? ( - {t("settings.appshotsAllowed")} - ) : ( - - )} - - - - {appshotSettings.accessibility ? ( - {t("settings.appshotsAllowed")} - ) : ( - - )} - - - - )} -
+ )} {tab === "browser-use" && ( - -

- {t("settings.browserUseNewSession")} -

- {browserUseError && ( -

- {browserUseError} -

- )} - {browserUseSettings?.errors.map((error) => ( -

- {error} -

- ))} - {!browserUseSettings ? ( -

{t("settings.browserUseLoading")}

- ) : ( - <> - - void saveAgentBrowserAccess(enabled)} - /> - - - - - - - {t("settings.browserUseBackends")} - {browserUseSettings.backends.map((backend) => ( - {backend.id}} - > - - - {backend.available - ? t("settings.browserUseAvailable") - : t("settings.browserUseUnavailable")} - - - ))} - - )} -
+ )} {tab === "providers" && ( - -
- {(providerOperation?.action === "refresh" || providerMessage?.id === "*") && ( - - {providerOperation?.action === "refresh" - ? t("settings.providerChecking") - : providerMessage?.text} - - )} - -
- {providerError?.id === "*" && ( -

{providerError.text}

- )} -
- {providers.map((p) => { - const enabled = p.enabled !== false; - const management = p.management ?? { - installed: p.available, - version: null, - latest_version: null, - update_available: null, - check_error: null, - install_supported: false, - upgrade_supported: false, - launch_mode: p.available ? "installed" as const : "unavailable" as const, - }; - const expanded = expandedProviders.has(p.id); - const operation = providerOperation?.id === p.id ? providerOperation.action : null; - const status = !enabled - ? t("settings.providerDisabled") - : management.installed - ? management.version - ? t("settings.providerInstalledVersion", { version: management.version }) - : t("settings.installed") - : management.launch_mode === "on_demand" - ? t("settings.providerReadyOnDemand") - : t("settings.notInstalled"); - return ( -
-
- - {!management.installed && management.install_supported && ( - - )} - {management.installed - && management.upgrade_supported - && management.update_available === true && ( - - )} - void saveProviderEnabled(p.id, checked)} - /> -
- {(providerMessage?.id === p.id || providerError?.id === p.id) && ( -

- {providerError?.id === p.id ? providerError.text : providerMessage?.text} -

- )} - {expanded && ( -
-
- {p.id} - - {management.launch_mode === "installed" - ? t("settings.providerLocalRuntime") - : management.launch_mode === "on_demand" - ? t("settings.providerOnDemandRuntime") - : t("settings.providerUnavailableRuntime")} - - {p.needs_node && {t("settings.needsNode")}} -
- {p.capabilities - .filter((capability) => capability.state !== "unavailable") - .map((capability) => ( -
-
-
- {CAPABILITY_LABELS[capability.id]} - {capability.experimental && Experimental} - {capability.version && ( - - {capability.version} - - )} -
- {capability.reason && ( -

- {capability.reason} -

- )} - {capability.fix && ( -

- {capability.fix} -

- )} -
- - - {capability.state} - -
- ))} -
- )} -
- ); - })} -
-
+ )} - {tab === "developer" && ( - - - void savePluginDeveloperMode(checked)} - aria-label={t("settings.developerMode")} - /> - - - {t("settings.pluginDevelopment")} - - - {pluginDevelopmentStatus} - {pluginReloadDetail && ( - - {pluginReloadDetail} - - )} - - )} - > - - - - - - - - {t("settings.supportDiagnostics")} - - - {diagnosticsMessage ?? t("settings.exportDiagnosticsHint")} - - )} - > - - - - {diagnosticsError && ( -

- {diagnosticsError} -

- )} - - {pluginDevelopmentError && ( -

- {pluginDevelopmentError} -

- )} -
+ )} - {tab === "browser" && ( - - } - label="Default browser adapter" - hint="Ordinary requests use C2 Browser. Explicit Chrome, existing-tab, or existing-login requests use Chrome." - > - Experimental - - Permanent website access - {browserOrigins.length === 0 ? ( -

No origins have permanent access.

- ) : ( - browserOrigins.map((origin) => ( - - - - )) - )} -
- )} + {tab === "browser" && }

diff --git a/apps/desktop/src/settings/SettingsPrimitives.tsx b/apps/desktop/src/settings/SettingsPrimitives.tsx new file mode 100644 index 00000000..5dc53805 --- /dev/null +++ b/apps/desktop/src/settings/SettingsPrimitives.tsx @@ -0,0 +1,80 @@ +import type { ReactNode } from "react"; + +import { SettingRow } from "@/components/business/setting-row"; +import { SettingsPanel } from "@/components/business/settings-panel"; +import { cn } from "@/lib/utils"; + +type RowProps = { + icon?: ReactNode; + label: string; + hint?: ReactNode; + compact?: boolean; + className?: string; + controlClassName?: string; + children: ReactNode; +}; + +/** Shared anatomy for every setting: description on the left, control on the right. */ +export function Row({ + icon, + label, + hint, + compact, + className, + controlClassName, + children, +}: RowProps) { + return ( + + {children} + + ); +} + +/** Project settings share one trailing control lane so fields and actions stay on the same grid. */ +export function ProjectRow(props: RowProps) { + return ( + + {props.children} + + ); +} + +export function GroupHeading({ children }: { children: ReactNode }) { + return ( +

+ {children} +

+ ); +} + +export function Page({ + title, + description, + children, +}: { + title: string; + description?: string; + children: ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/apps/desktop/src/settings/WorktreeSettings.tsx b/apps/desktop/src/settings/WorktreeSettings.tsx new file mode 100644 index 00000000..7f95c160 --- /dev/null +++ b/apps/desktop/src/settings/WorktreeSettings.tsx @@ -0,0 +1,393 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { MessageSquare, RefreshCw, Trash2 } from "@/components/ui/icons"; + +import { + confirmNative, + discardOrphanWorktree, + discardSessionWorktree, + getWorktreeSettings, + listProjectWorktrees, + updateWorktreeSettings, + type Project, + type WorktreeEntryKind, + type WorktreeSettings, + type WorktreeStatusEntry, +} from "../bridge"; +import { useT } from "../i18n"; +import type { StringKey } from "../i18n/strings"; +import { ProjectIcon } from "../projects/ProjectIcon"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/spinner"; +import { Switch } from "@/components/ui/switch"; +import { Page, Row } from "./SettingsPrimitives"; +import { + worktreeBranchDisplay, + worktreeDiscardRoute, + worktreeStatusBadges, + type WorktreeStatusBadge, +} from "./worktrees"; + +type ProjectWorktreeState = { + entries: WorktreeStatusEntry[]; + error: string | null; +}; + +const WORKTREE_KIND_LABELS: Record = { + session: "worktree.kindSession", + orphan: "worktree.kindOrphan", + stale: "worktree.kindStale", +}; + +const WORKTREE_BADGE_LABELS: Record = { + archived: "worktree.badgeArchived", + discarded: "worktree.badgeDiscarded", + checkoutMissing: "worktree.badgeCheckoutMissing", +}; + +export function WorktreeSettingsPage({ + projects, + onOpenSession = () => {}, + lister = listProjectWorktrees, + settingsLoader = getWorktreeSettings, + settingsSaver = updateWorktreeSettings, + sessionDiscarder = discardSessionWorktree, + orphanDiscarder = discardOrphanWorktree, + confirmer = confirmNative, +}: { + projects: Project[]; + onOpenSession?: (sessionId: string) => void; + lister?: typeof listProjectWorktrees; + settingsLoader?: () => Promise; + settingsSaver?: (settings: WorktreeSettings) => Promise; + sessionDiscarder?: typeof discardSessionWorktree; + orphanDiscarder?: typeof discardOrphanWorktree; + confirmer?: typeof confirmNative; +}) { + const t = useT(); + const [worktreesByProject, setWorktreesByProject] = useState>({}); + const [worktreesLoading, setWorktreesLoading] = useState(false); + const [worktreeSettings, setWorktreeSettings] = useState(null); + const [worktreeSettingsSaving, setWorktreeSettingsSaving] = useState(false); + const [worktreeSettingsError, setWorktreeSettingsError] = useState(null); + const [worktreeRootDraft, setWorktreeRootDraft] = useState(""); + const [worktreeLimitDraft, setWorktreeLimitDraft] = useState("15"); + const [discardingWorktree, setDiscardingWorktree] = useState(null); + const requestRef = useRef(0); + + const loadWorktrees = useCallback(async (projectList: Project[]) => { + const request = ++requestRef.current; + setWorktreesLoading(true); + const results = await Promise.all(projectList.map(async (candidate) => { + try { + return [candidate.path, { entries: await lister(candidate.path), error: null }] as const; + } catch (cause) { + return [candidate.path, { + entries: [], + error: t("worktree.manageFailed", { error: String(cause) }), + }] as const; + } + })); + if (request !== requestRef.current) return; + setWorktreesByProject(Object.fromEntries(results)); + setWorktreesLoading(false); + }, [lister, t]); + + useEffect(() => { + void loadWorktrees(projects); + return () => { + requestRef.current += 1; + }; + }, [loadWorktrees, projects]); + + useEffect(() => { + let active = true; + setWorktreeSettingsError(null); + void settingsLoader().then((settings) => { + if (!active) return; + setWorktreeSettings(settings); + setWorktreeRootDraft(settings.root ?? ""); + setWorktreeLimitDraft(String(settings.auto_delete_limit)); + }).catch((cause) => { + if (active) setWorktreeSettingsError(t("worktree.settingsLoadFailed", { error: String(cause) })); + }); + return () => { + active = false; + }; + }, [settingsLoader, t]); + + async function loadProjectWorktrees(path: string) { + try { + const entries = await lister(path); + setWorktreesByProject((current) => ({ ...current, [path]: { entries, error: null } })); + } catch (cause) { + setWorktreesByProject((current) => ({ + ...current, + [path]: { + entries: [], + error: t("worktree.manageFailed", { error: String(cause) }), + }, + })); + } + } + + async function saveGlobalWorktreeSettings(patch: Partial) { + if (!worktreeSettings) return false; + setWorktreeSettingsSaving(true); + setWorktreeSettingsError(null); + try { + const saved = await settingsSaver({ ...worktreeSettings, ...patch }); + setWorktreeSettings(saved); + setWorktreeRootDraft(saved.root ?? ""); + setWorktreeLimitDraft(String(saved.auto_delete_limit)); + if ( + Object.prototype.hasOwnProperty.call(patch, "root") + || Object.prototype.hasOwnProperty.call(patch, "auto_delete") + ) { + await loadWorktrees(projects); + } + return true; + } catch (cause) { + setWorktreeSettingsError(t("worktree.settingsSaveFailed", { error: String(cause) })); + setWorktreeRootDraft(worktreeSettings.root ?? ""); + setWorktreeLimitDraft(String(worktreeSettings.auto_delete_limit)); + return false; + } finally { + setWorktreeSettingsSaving(false); + } + } + + function commitWorktreeRoot() { + if (!worktreeSettings) return; + const root = worktreeRootDraft.trim() || undefined; + if (root === worktreeSettings.root) return; + void saveGlobalWorktreeSettings({ root }); + } + + function commitWorktreeLimit() { + if (!worktreeSettings) return; + const parsed = Number.parseInt(worktreeLimitDraft, 10); + const limit = Number.isFinite(parsed) + ? Math.min(1000, Math.max(1, parsed)) + : worktreeSettings.auto_delete_limit; + setWorktreeLimitDraft(String(limit)); + if (limit !== worktreeSettings.auto_delete_limit) { + void saveGlobalWorktreeSettings({ auto_delete_limit: limit }); + } + } + + async function discardWorktree(projectPath: string, entry: WorktreeStatusEntry) { + if (!(await confirmer(t("worktree.discardConfirm", { path: entry.path })))) return; + setDiscardingWorktree(entry.path); + try { + const route = worktreeDiscardRoute(entry); + if (route.kind === "session") await sessionDiscarder(route.session); + else await orphanDiscarder(projectPath, route.worktreePath); + await loadProjectWorktrees(projectPath); + } catch (cause) { + setWorktreesByProject((current) => ({ + ...current, + [projectPath]: { + entries: current[projectPath]?.entries ?? [], + error: t("worktree.discardFailed", { error: String(cause) }), + }, + })); + } finally { + setDiscardingWorktree(null); + } + } + + return ( + +
+ {worktreeSettings ? ( + <> + + setWorktreeRootDraft(event.target.value)} + onBlur={commitWorktreeRoot} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + }} + /> + + + { + void saveGlobalWorktreeSettings({ fetch_upstream }); + }} + /> + + + { + void saveGlobalWorktreeSettings({ auto_delete }); + }} + /> + + + setWorktreeLimitDraft(event.target.value)} + onBlur={commitWorktreeLimit} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + }} + /> + + + ) : ( +

+ {t("worktree.settingsLoading")} +

+ )} +
+ {worktreeSettingsError ? ( +

+ {worktreeSettingsError} +

+ ) : null} + +
+ +
+ + {projects.length === 0 ? ( +

{t("worktree.manageNoProjects")}

+ ) : worktreesLoading && Object.keys(worktreesByProject).length === 0 ? ( +

{t("worktree.manageLoading")}

+ ) : ( + projects.map((candidate) => { + const state = worktreesByProject[candidate.path] ?? { entries: [], error: null }; + return ( +
+
+ +
+

{candidate.name}

+

+ {candidate.path} +

+
+ + {t("worktree.count", { count: state.entries.length })} + +
+ +
+ {state.error ? ( +

+ {state.error} +

+ ) : state.entries.length === 0 ? ( +

+ {t("worktree.manageEmpty")} +

+ ) : ( + state.entries.map((entry) => { + const branch = worktreeBranchDisplay(entry.branch); + return ( + + + {t(WORKTREE_KIND_LABELS[entry.kind])} + {worktreeStatusBadges(entry).map((badge) => ( + + {t(WORKTREE_BADGE_LABELS[badge])} + + ))} + {branch && {branch}} + + + {entry.path} + + + )} + > + {entry.session_id ? ( + + ) : null} + + + ); + }) + )} +
+
+ ); + }) + )} +
+ ); +} diff --git a/apps/desktop/src/sidebar/SessionRail.tsx b/apps/desktop/src/sidebar/SessionRail.tsx index e347199f..10ecbaac 100644 --- a/apps/desktop/src/sidebar/SessionRail.tsx +++ b/apps/desktop/src/sidebar/SessionRail.tsx @@ -44,8 +44,8 @@ import { openNativePath, providerLabel, type Project, type SessionInfo } from ". import { nativeContextMenusAvailable, showNativeContextMenu, -} from "../electrobun/contextMenu"; -import type { NativeContextMenuItem } from "../electrobun/rpc"; + type NativeContextMenuItem, +} from "../container"; import { ProviderIcon } from "../providers/ProviderIcon"; import { NavigationRow } from "@/components/business/navigation-row"; import { QuotaProgress } from "@/components/business/quota-progress"; @@ -835,10 +835,10 @@ export function SessionRail({ {/* ---- 1 · title ---------------------------------------------------------------------- */} {/* Keep the collapse control in the title row, with enough clearance for macOS traffic - lights. Search gets a full-width launcher below so it is visible and easy to target. */} + lights. Search gets a full-width launcher below; all panes share the same 40px baseline. */}
diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 66c35a1a..68cedbbf 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -419,6 +419,10 @@ .macos-window-glass .app-shell { background: transparent; } + .window-titlebar { + height: calc(var(--ds-control-normal) + var(--ds-space-surface-inset)); + box-shadow: inset 0 calc(-1 * var(--hairline-width)) 0 var(--border); + } .window-controls-safe-main { padding-left: 1rem; } @@ -496,6 +500,13 @@ container-name: dock; container-type: inline-size; } + .dock-content-tabbar { + height: calc(var(--ds-control-field) + var(--ds-space-inline)); + box-shadow: inset 0 -1px var(--border); + } + .dock-content-split { + box-shadow: inset 1px 0 var(--border); + } @container dock (max-width: 359px) { .dock-tab-label { display: none; diff --git a/apps/desktop/src/terminal/TerminalDockContent.tsx b/apps/desktop/src/terminal/TerminalDockContent.tsx new file mode 100644 index 00000000..bb3e80b0 --- /dev/null +++ b/apps/desktop/src/terminal/TerminalDockContent.tsx @@ -0,0 +1,145 @@ +import { useCallback, useEffect, useState } from "react"; +import { CornerUpLeft, Plus, X } from "@/components/ui/icons"; + +import { onPtyTitle, ptyDump, ptyKill } from "../bridge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { useT } from "../i18n"; +import { cn } from "@/lib/utils"; +import { TerminalPanel } from "./Terminal"; + +function terminalId(sessionKey: string, slot: number, tmux: boolean): string { + return `${sessionKey}-${slot}${tmux ? "-tmux" : ""}`; +} + +function terminalLabel(title: string | undefined, slot: number): string { + if (!title) return String(slot); + return title.split("/").filter(Boolean).pop() ?? title; +} + +type TerminalDockContentProps = { + cwd: string | null; + projectPath: string | null; + sessionKey: string; + onSendText: (text: string) => void; +}; + +/** Terminal-specific tabs and lifecycle, rendered inside the generic Dock container. */ +export function TerminalDockContent({ + cwd, + projectPath, + sessionKey, + onSendText, +}: TerminalDockContentProps) { + const t = useT(); + const [slots, setSlots] = useState([1]); + const [activeSlot, setActiveSlot] = useState(1); + const [nextSlot, setNextSlot] = useState(2); + const [tmux, setTmux] = useState(false); + const [titles, setTitles] = useState>({}); + + useEffect(() => { + let stop: (() => void) | null = null; + setTitles({}); + void (async () => { + stop = await onPtyTitle(({ id, title, project_path }) => { + if (project_path !== projectPath) return; + setTitles((current) => ({ ...current, [id]: title })); + }); + })(); + return () => stop?.(); + }, [projectPath]); + + const activeId = terminalId(sessionKey, activeSlot, tmux); + const sendToAgent = useCallback(async () => { + const text = (await ptyDump(activeId, true)).trimEnd(); + if (text) onSendText(text); + }, [activeId, onSendText]); + + function closeSlot(slot: number) { + const remaining = slots.filter((candidate) => candidate !== slot); + setSlots(remaining); + if (activeSlot === slot && remaining[0]) setActiveSlot(remaining[0]); + void ptyKill(terminalId(sessionKey, slot, false)); + void ptyKill(terminalId(sessionKey, slot, true)); + } + + return ( +
+
+ {slots.map((slot) => ( + + ))} + +
+ + +
+ {slots.map((slot) => ( +
+ +
+ ))} +
+ ); +} diff --git a/apps/desktop/tests/appshotsContract.test.ts b/apps/desktop/tests/appshotsContract.test.ts index 81ccb674..d964c99b 100644 --- a/apps/desktop/tests/appshotsContract.test.ts +++ b/apps/desktop/tests/appshotsContract.test.ts @@ -5,11 +5,13 @@ const source = (path: string) => readFileSync(new URL(`../${path}`, import.meta. describe("Appshots desktop contract", () => { test("offers the requested settings and routes a capture into the Composer", () => { - const settings = source("src/settings/SettingsPage.tsx"); + const settingsShell = source("src/settings/SettingsPage.tsx"); + const settings = source("src/settings/AppshotsSettings.tsx"); const app = source("src/App.tsx"); const composer = source("src/session/Composer.tsx"); - expect(settings).toContain('{ id: "appshots", icon: ScanText, labelKey: "settings.appshots" }'); + expect(settingsShell).toContain('{ id: "appshots", icon: ScanText, labelKey: "settings.appshots" }'); + expect(settingsShell).toContain("'); expect(settings).toContain(''); expect(settings).toContain('label={t("settings.appshotsFrontmost")}'); diff --git a/apps/desktop/tests/containerBoundary.test.ts b/apps/desktop/tests/containerBoundary.test.ts new file mode 100644 index 00000000..d706c8d9 --- /dev/null +++ b/apps/desktop/tests/containerBoundary.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync, readdirSync } from "node:fs"; +import { relative, resolve } from "node:path"; + +const sourceRoot = resolve(import.meta.dir, "../src"); + +function sourceFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = resolve(directory, entry.name); + if (entry.isDirectory()) return sourceFiles(path); + return /\.(?:ts|tsx)$/.test(entry.name) ? [path] : []; + }); +} + +function isDesktopImplementation(path: string): boolean { + const name = relative(sourceRoot, path); + return ( + name === "container.ts" + || name === "browser/electrobun.ts" + || name.startsWith("electrobun/") + ); +} + +describe("desktop container boundary", () => { + test("keeps Electrobun imports out of product content", () => { + const violations = sourceFiles(sourceRoot) + .filter((path) => !isDesktopImplementation(path)) + .flatMap((path) => { + const source = readFileSync(path, "utf8"); + return [...source.matchAll(/\b(?:from\s+|import\s*(?:\(\s*)?)["']([^"']+)["']/g)] + .map((match) => match[1]) + .filter((specifier) => + specifier === "electrobun" + || specifier.startsWith("electrobun/") + || /(?:^|\/)electrobun(?:\/|$)/.test(specifier) + ) + .map((specifier) => `${relative(sourceRoot, path)} -> ${specifier}`); + }); + + expect(violations).toEqual([]); + }); + + test("routes the product bridge through the container port", () => { + const bridge = readFileSync(resolve(sourceRoot, "bridge.ts"), "utf8"); + const container = readFileSync(resolve(sourceRoot, "container.ts"), "utf8"); + + expect(bridge).toContain('from "./container"'); + expect(bridge).not.toContain('from "./electrobun/'); + expect(bridge).not.toContain('from "./browser/electrobun"'); + expect(container).toContain('from "./electrobun/client"'); + expect(container).toContain('from "./browser/electrobun"'); + }); +}); diff --git a/apps/desktop/tests/dockArchitecture.test.ts b/apps/desktop/tests/dockArchitecture.test.ts new file mode 100644 index 00000000..8537ad4c --- /dev/null +++ b/apps/desktop/tests/dockArchitecture.test.ts @@ -0,0 +1,40 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "bun:test"; + +const source = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8"); + +describe("Dock container and content seam", () => { + test("keeps feature implementations out of the Dock container", () => { + const dock = source("src/dock/Dock.tsx"); + + expect(dock).toContain("content?: DockContentMap"); + expect(dock).toContain("{content[id]}"); + expect(dock).not.toContain("../browser/"); + expect(dock).not.toContain("../terminal/"); + expect(dock).not.toContain("../files/"); + expect(dock).not.toContain("../git/"); + expect(dock).not.toContain("../session/TrajectoryView"); + expect(dock).not.toContain("ptyDump"); + expect(dock).not.toContain("useDirtyPaths"); + }); + + test("composes feature-owned content at the application shell", () => { + const app = source("src/App.tsx"); + const terminal = source("src/terminal/TerminalDockContent.tsx"); + const files = source("src/files/FileDockContent.tsx"); + const git = source("src/git/GitDockContent.tsx"); + + expect(app).toContain("content={{"); + expect(app).toContain(" {}} onOpenSideChat={onOpenSideChat} onClose={() => {}} - cwd={null} - projectPath={null} - sessionKey="test" - git={null} - onRefreshGit={() => {}} - onOpenSourceControl={() => {}} - browserUrl="about:blank" - onNavigate={() => {}} - onAnnotate={() => {}} - onInsertFile={() => {}} - onOpenFile={() => {}} - onSendText={() => {}} - openFiles={[]} - activeFile={null} - fileReveal={null} - onActiveFile={() => {}} - onCloseFile={() => {}} - turns={[]} - usage={null} - hasEarlier={false} - loadingEarlier={false} - onLoadEarlier={() => {}} + content={{ + trajectory: ( +
No events match this view.
+ ), + }} width={440} onWidth={() => {}} /> @@ -140,7 +123,7 @@ describe("Dock plugin component gate", () => { view.unmount(); }); - test("renders trajectory as a right-panel module", async () => { + test("renders caller-supplied trajectory content as a right-panel module", async () => { activateDom(); const home = renderDock(["trajectory"], "home"); await flush(); diff --git a/apps/desktop/tests/pluginBridgeContract.test.ts b/apps/desktop/tests/pluginBridgeContract.test.ts index 6429e33b..65edc6ac 100644 --- a/apps/desktop/tests/pluginBridgeContract.test.ts +++ b/apps/desktop/tests/pluginBridgeContract.test.ts @@ -20,7 +20,7 @@ describe("plugin bridge contract", () => { const adapter = readFileSync(resolve(desktop, "src/electrobun/nativeHost.ts"), "utf8"); const host = readFileSync(resolve(desktop, "src-host/src/lib.rs"), "utf8"); const enginePlugin = readFileSync( - resolve(repository, "crates/core/src/app/plugins/engine.rs"), + resolve(repository, "crates/plugins/src/app/plugins/engine.rs"), "utf8", ); const config = readFileSync(resolve(desktop, "electrobun.config.ts"), "utf8"); @@ -69,7 +69,7 @@ describe("plugin bridge contract", () => { test("registers every static command used by the renderer bridge", () => { const bridge = readFileSync(resolve(desktop, "src/bridge.ts"), "utf8"); const pluginSources = [ - ...rustFiles(resolve(repository, "crates/core/src/app/plugins")), + ...rustFiles(resolve(repository, "crates/plugins/src/app/plugins")), ...rustFiles(resolve(desktop, "src-host/src")), ] .map((path) => readFileSync(path, "utf8")) diff --git a/apps/desktop/tests/settingsLayoutContract.test.ts b/apps/desktop/tests/settingsLayoutContract.test.ts index 94900567..5744b653 100644 --- a/apps/desktop/tests/settingsLayoutContract.test.ts +++ b/apps/desktop/tests/settingsLayoutContract.test.ts @@ -2,10 +2,26 @@ import { readFileSync } from "node:fs"; import { describe, expect, test } from "bun:test"; const source = readFileSync(new URL("../src/settings/SettingsPage.tsx", import.meta.url), "utf8"); +const personalSource = readFileSync(new URL("../src/settings/PersonalSettings.tsx", import.meta.url), "utf8"); +const primitivesSource = readFileSync(new URL("../src/settings/SettingsPrimitives.tsx", import.meta.url), "utf8"); const appSource = readFileSync(new URL("../src/App.tsx", import.meta.url), "utf8"); const styles = readFileSync(new URL("../src/settings/settings-page.css", import.meta.url), "utf8"); const petStyles = readFileSync(new URL("../src/settings/pet-settings.css", import.meta.url), "utf8"); +const CONTENT_MODULES = [ + "GeneralSettingsPage", + "ImportSettingsPage", + "KeybindingsSettingsPage", + "ProjectSettingsPage", + "WorktreeSettingsPage", + "ProviderSettingsPage", + "ComputerUseSettingsPage", + "BrowserUseSettingsPage", + "AppshotsSettingsPage", + "DeviceSyncSettingsPage", + "DeveloperSettingsPage", +] as const; + describe("Settings page layout contract", () => { test("places the Back action above the settings menu", () => { const backIndex = source.indexOf("data-settings-back"); @@ -37,7 +53,7 @@ describe("Settings page layout contract", () => { test("collapses navigation labels and stacks regular rows when space is constrained", () => { expect(source).toContain("settings-nav-label"); - expect(source).toContain("settings-row-control"); + expect(primitivesSource).toContain("settings-row-control"); expect(styles).toContain("@media (max-width: 44rem)"); expect(styles).toMatch(/\.settings-sidebar \{[\s\S]*?width: 4rem;/); expect(styles).toContain("@container settings-page (max-width: 36rem)"); @@ -103,13 +119,14 @@ describe("Settings page layout contract", () => { test("includes session import as a first-class personal panel", () => { expect(source).toMatch(/\{ id: "import", icon: Download, labelKey: "settings\.import" \}/); expect(source).toContain('{tab === "import" && ('); - expect(source).toContain("data-session-import-result"); + expect(personalSource).toContain("data-session-import-result"); }); test("includes Pets as a first-class settings panel", () => { expect(source).toMatch(/\{ id: "pets", icon: PawPrint, labelKey: "settings\.pets" \}/); expect(source).toContain('{tab === "pets" && ('); - expect(source).toMatch(//); + expect(source).toMatch(/[\s\S]*?/); + expect(primitivesSource).toContain(" { @@ -126,4 +143,16 @@ describe("Settings page layout contract", () => { /\{tab === "memory" && memoryEnabled && \([\s\S]*?(?:[\s\S]*?)\)\}/, ); }); + + test("keeps the settings shell separate from stateful content modules", () => { + for (const module of CONTENT_MODULES) { + expect(source).toContain(`<${module}`); + } + expect(source).not.toContain("setAppUpdate"); + expect(source).not.toContain("setAppshotSettings"); + expect(source).not.toContain("setProviderOperation"); + expect(source).not.toContain("setComputerUseSettings"); + expect(source).not.toContain("setWorktreesByProject"); + expect(source).not.toContain("setProjectNameDraft"); + }); }); diff --git a/apps/desktop/tests/t3RemoteContract.test.ts b/apps/desktop/tests/t3RemoteContract.test.ts index aa2da8c9..551c4a3c 100644 --- a/apps/desktop/tests/t3RemoteContract.test.ts +++ b/apps/desktop/tests/t3RemoteContract.test.ts @@ -26,7 +26,7 @@ describe("Rust Plugin Kernel remote contract", () => { test("routes task transfer through the Rust handoff plugin and native agent", () => { const bridge = read("src/bridge.ts"); - const handoff = read("../../crates/core/src/app/plugins/handoff.rs"); + const handoff = read("../../crates/plugins/src/app/plugins/handoff.rs"); const agent = read("../../crates/server/src/bin/codetwo-agent.rs"); expect(bridge).toContain('call("handoff.transfer_pairing"'); diff --git a/apps/desktop/tests/windowChromeContract.test.ts b/apps/desktop/tests/windowChromeContract.test.ts index 7f4764c4..194d4366 100644 --- a/apps/desktop/tests/windowChromeContract.test.ts +++ b/apps/desktop/tests/windowChromeContract.test.ts @@ -21,11 +21,11 @@ const nativeWindowEffects = source("../native/window-effects/CodeTwoWindowEffect const themeSource = source("../src/theme.tsx"); describe("macOS window chrome contract", () => { - test("centers the native macOS traffic lights in the 48px titlebar", () => { + test("centers the native macOS traffic lights in the 40px titlebar", () => { expect(electrobunHost).toContain('titleBarStyle: "hiddenInset"'); expect(electrobunHost).not.toContain("trafficLightOffset"); expect(electrobunHost).toMatch( - /mainWindow\.webview\.on\("dom-ready", \(\) => \{[\s\S]*?if \(process\.platform === "darwin"\) \{[\s\S]*?mainWindow\.setWindowButtonPosition\(22, 17\);[\s\S]*?\}\s*rendererReady = true;/, + /mainWindow\.webview\.on\("dom-ready", \(\) => \{[\s\S]*?if \(process\.platform === "darwin"\) \{[\s\S]*?mainWindow\.setWindowButtonPosition\(22, 13\);[\s\S]*?\}\s*rendererReady = true;/, ); }); @@ -113,15 +113,28 @@ describe("macOS window chrome contract", () => { ); }); - test("keeps both dock header states aligned to the 48px titlebar", () => { + test("keeps the rail, workspace, and both dock states on one 40px titlebar baseline", () => { const titlebarClasses = Array.from( dockSource.matchAll(/data-dock-titlebar[\s\S]*?className="([^"]+)"/g), (match) => match[1].split(/\s+/), ); + expect(styles).toMatch( + /\.window-titlebar\s*{[^}]*height:\s*calc\(var\(--ds-control-normal\) \+ var\(--ds-space-surface-inset\)\);/s, + ); + expect(styles).toMatch( + /\.window-titlebar\s*{[^}]*box-shadow:\s*inset 0 calc\(-1 \* var\(--hairline-width\)\) 0 var\(--border\);/s, + ); + expect(appSource).toContain( + '"session-header window-titlebar electrobun-webkit-app-region-drag flex min-w-0 shrink-0 items-center gap-2 pr-4"', + ); + expect(railSource).toContain( + 'className="window-titlebar window-controls-safe-rail electrobun-webkit-app-region-drag flex shrink-0 items-center gap-1 pr-2"', + ); expect(titlebarClasses).toHaveLength(2); - expect(titlebarClasses.every((classes) => classes.includes("h-titlebar"))).toBe(true); + expect(titlebarClasses.every((classes) => classes.includes("window-titlebar"))).toBe(true); expect(titlebarClasses.every((classes) => !classes.includes("py-2.5"))).toBe(true); + expect(titlebarClasses.every((classes) => !classes.includes("border-b"))).toBe(true); expect(dockSource).toContain( 'size="compact" className="w-(--ds-control-normal) px-0" onClick={onClose}', ); diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 92e2872d..c534bbbd 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -4,10 +4,12 @@ version.workspace = true edition.workspace = true license.workspace = true rust-version.workspace = true -description = "Shared core for C2: ACP client, provider registry, sessions, skills, permissions." +description = "Shared C2 product core: ACP, providers, sessions, skills, policy, and persistence." + +[features] +terminal = ["dep:libghostty-vt"] [dependencies] -codetwo-kernel.workspace = true tokio.workspace = true serde.workspace = true serde_json.workspace = true @@ -27,11 +29,10 @@ toml = "0.8" chrono = "0.4" chrono-tz = "0.10" tempfile = "3" -notify = "8.2" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } # Ghostty's VT engine (parsing, scrollback, reflow) behind a C API. NOTE: this builds Ghostty from # source with Zig — see the terminal section of docs/architecture.md for the toolchain requirement. -libghostty-vt = "0.2" +libghostty-vt = { version = "0.2", optional = true } [target.'cfg(target_os = "macos")'.dependencies] block2 = "0.6" diff --git a/crates/core/src/harness.rs b/crates/core/src/harness.rs index 67ce0d08..0528a7e9 100644 --- a/crates/core/src/harness.rs +++ b/crates/core/src/harness.rs @@ -94,13 +94,18 @@ pub fn discover_in(home: Option<&Path>, cwd: Option<&Path>) -> Vec { pub fn source_label(skill_id: &str) -> Option<&'static str> { let rest = skill_id.strip_prefix("harness:")?; let (harness_id, _) = rest.split_once(':')?; - HARNESSES.iter().find(|h| h.id == harness_id).map(|h| h.label) + HARNESSES + .iter() + .find(|h| h.id == harness_id) + .map(|h| h.label) } /// Read `//SKILL.md` entries into `out`, skipping ids an earlier (higher-precedence) /// root already produced. fn scan_root(root: &Path, spec: &HarnessSpec, out: &mut Vec) { - let Ok(entries) = std::fs::read_dir(root) else { return }; + let Ok(entries) = std::fs::read_dir(root) else { + return; + }; for entry in entries.flatten() { let dir = entry.path(); let Some(dir_name) = dir.file_name().and_then(|s| s.to_str()).map(str::to_string) else { @@ -110,7 +115,9 @@ fn scan_root(root: &Path, spec: &HarnessSpec, out: &mut Vec) { continue; } // Also rejects stray files: `/SKILL.md` can't be read. - let Ok(text) = std::fs::read_to_string(dir.join("SKILL.md")) else { continue }; + let Ok(text) = std::fs::read_to_string(dir.join("SKILL.md")) else { + continue; + }; let id = format!("harness:{}:{dir_name}", spec.id); if out.iter().any(|s| s.id == id) { continue; @@ -121,7 +128,11 @@ fn scan_root(root: &Path, spec: &HarnessSpec, out: &mut Vec) { let name = name.unwrap_or_else(|| dir_name.clone()); let mut description = description.unwrap_or_default(); if description.chars().count() > MAX_DESCRIPTION_CHARS { - description = description.chars().take(MAX_DESCRIPTION_CHARS).collect::() + "…"; + description = description + .chars() + .take(MAX_DESCRIPTION_CHARS) + .collect::() + + "…"; } // No icon: discovered skills take the picker's neutral fallback glyph rather than // inventing an emoji per product. @@ -131,7 +142,10 @@ fn scan_root(root: &Path, spec: &HarnessSpec, out: &mut Vec) { description, icon: None, source: Some(spec.label.to_string()), - payload: SkillPayload::AgentSkill { skill_ref: name, inline_text: None }, + payload: SkillPayload::AgentSkill { + skill_ref: name, + inline_text: None, + }, }); } } @@ -140,7 +154,7 @@ fn scan_root(root: &Path, spec: &HarnessSpec, out: &mut Vec) { /// top-level `key: value` pairs (plain or quoted) plus indented continuation lines / `>`-style /// block scalars for multi-line descriptions. Anything fancier still loads — unknown keys are /// ignored and missing ones fall back to the directory name / empty. -pub(crate) fn parse_frontmatter(text: &str) -> (Option, Option) { +pub fn parse_frontmatter(text: &str) -> (Option, Option) { let mut lines = text.lines(); if lines.next().map(str::trim_end) != Some("---") { return (None, None); @@ -149,7 +163,11 @@ pub(crate) fn parse_frontmatter(text: &str) -> (Option, Option) let mut description = None; // The key still collecting continuation lines, and what it has so far. let mut open: Option<(bool, String)> = None; // (is_name, value) - fn flush(open: &mut Option<(bool, String)>, name: &mut Option, description: &mut Option) { + fn flush( + open: &mut Option<(bool, String)>, + name: &mut Option, + description: &mut Option, + ) { if let Some((is_name, value)) = open.take() { let value = value.trim().to_string(); if !value.is_empty() { @@ -173,7 +191,9 @@ pub(crate) fn parse_frontmatter(text: &str) -> (Option, Option) continue; } flush(&mut open, &mut name, &mut description); - let Some((key, value)) = line.split_once(':') else { continue }; + let Some((key, value)) = line.split_once(':') else { + continue; + }; let is_name = match key.trim() { "name" => true, "description" => false, @@ -182,8 +202,11 @@ pub(crate) fn parse_frontmatter(text: &str) -> (Option, Option) let value = value.trim(); // `>` / `|` (with optional chomping `-`) start a block scalar: the value is the indented // lines that follow. - let seed = - if matches!(value, ">" | ">-" | "|" | "|-") { String::new() } else { unquote(value).to_string() }; + let seed = if matches!(value, ">" | ">-" | "|" | "|-") { + String::new() + } else { + unquote(value).to_string() + }; open = Some((is_name, seed)); } flush(&mut open, &mut name, &mut description); @@ -226,21 +249,43 @@ mod tests { "code-review", "---\nname: code-review\ndescription: Review a pull request\n---\nbody", ); - write_skill(&home.join(".codex/skills"), "deploy", "No frontmatter at all."); - write_skill(&cwd.join(".opencode/skill"), "docs", "---\nname: docs\n---\n"); + write_skill( + &home.join(".codex/skills"), + "deploy", + "No frontmatter at all.", + ); + write_skill( + &cwd.join(".opencode/skill"), + "docs", + "---\nname: docs\n---\n", + ); std::fs::create_dir_all(home.join(".claude/skills/empty-no-md")).unwrap(); - write_skill(&home.join(".claude/skills"), ".hidden", "---\nname: h\n---\n"); + write_skill( + &home.join(".claude/skills"), + ".hidden", + "---\nname: h\n---\n", + ); let skills = discover_in(Some(&home), Some(&cwd)); let ids: Vec<&str> = skills.iter().map(|s| s.id.as_str()).collect(); - assert_eq!(ids, vec!["harness:claude:code-review", "harness:codex:deploy", "harness:opencode:docs"]); + assert_eq!( + ids, + vec![ + "harness:claude:code-review", + "harness:codex:deploy", + "harness:opencode:docs" + ] + ); let review = &skills[0]; assert_eq!(review.name, "code-review"); assert_eq!(review.description, "Review a pull request"); assert_eq!( review.payload, - SkillPayload::AgentSkill { skill_ref: "code-review".into(), inline_text: None } + SkillPayload::AgentSkill { + skill_ref: "code-review".into(), + inline_text: None + } ); // No frontmatter → directory name, empty description. assert_eq!(skills[1].name, "deploy"); @@ -254,11 +299,20 @@ mod tests { #[test] fn project_skill_shadows_user_skill() { - let tmp = std::env::temp_dir().join(format!("codetwo-harness-shadow-{}", uuid::Uuid::new_v4())); + let tmp = + std::env::temp_dir().join(format!("codetwo-harness-shadow-{}", uuid::Uuid::new_v4())); let home = tmp.join("home"); let cwd = tmp.join("proj"); - write_skill(&home.join(".claude/skills"), "review", "---\ndescription: user copy\n---\n"); - write_skill(&cwd.join(".claude/skills"), "review", "---\ndescription: project copy\n---\n"); + write_skill( + &home.join(".claude/skills"), + "review", + "---\ndescription: user copy\n---\n", + ); + write_skill( + &cwd.join(".claude/skills"), + "review", + "---\ndescription: project copy\n---\n", + ); let skills = discover_in(Some(&home), Some(&cwd)); assert_eq!(skills.len(), 1); @@ -269,10 +323,8 @@ mod tests { #[test] fn opencode_v2_plural_skill_root_shadows_legacy_singular_root() { - let tmp = std::env::temp_dir().join(format!( - "codetwo-opencode2-skills-{}", - uuid::Uuid::new_v4() - )); + let tmp = + std::env::temp_dir().join(format!("codetwo-opencode2-skills-{}", uuid::Uuid::new_v4())); let cwd = tmp.join("proj"); write_skill( &cwd.join(".opencode/skills"), @@ -294,12 +346,20 @@ mod tests { #[test] fn discovered_skill_compiles_as_agent_skill() { - let tmp = std::env::temp_dir().join(format!("codetwo-harness-compile-{}", uuid::Uuid::new_v4())); + let tmp = + std::env::temp_dir().join(format!("codetwo-harness-compile-{}", uuid::Uuid::new_v4())); let home = tmp.join("home"); - write_skill(&home.join(".claude/skills"), "pdf", "---\nname: pdf\ndescription: Work with PDFs\n---\n"); + write_skill( + &home.join(".claude/skills"), + "pdf", + "---\nname: pdf\ndescription: Work with PDFs\n---\n", + ); let lib = SkillLibrary::new(discover_in(Some(&home), None)); - let doc = vec![DocBlock::Skill { skill_id: "harness:claude:pdf".into(), params: HashMap::new() }]; + let doc = vec![DocBlock::Skill { + skill_id: "harness:claude:pdf".into(), + params: HashMap::new(), + }]; let c = compile(&doc, &lib); assert_eq!(c.agent_skills, vec!["pdf".to_string()]); assert!(c.prompt.contains("Use the **pdf** skill.")); diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index ec3a02fe..c184b8c5 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,11 +1,10 @@ -//! C2 core — the shared brain behind both the Electrobun desktop app and the ratatui TUI. +//! C2 core — product domain and execution capabilities shared by every host. //! -//! Nothing in here knows about a UI. Frontends drive the core through the SQ/EQ interface -//! ([`Op`] in, [`Event`] out) and render the [`Event`] stream however they like. +//! Nothing in here depends on a UI, Kernel lifecycle, extension Bundle, or host protocol. The +//! `codetwo-plugins` crate adapts these capabilities into runtime modules and exposes `CoreApp` to +//! desktop, TUI, and server hosts. //! //! Module map: -//! - [`app`] — the plugin graph: every subsystem below, wired by declaration instead of by a -//! constructor. Start here; the modules it lists are its parts. //! - [`acp`] — Agent Client Protocol client (JSON-RPC over stdio) used to drive provider CLIs. //! - [`provider`] — registry of provider launch specs (Claude Code / Codex / Grok). //! - [`models`] — built-in model lists for providers that don't report their own over ACP. @@ -18,7 +17,6 @@ pub mod acp; pub mod activity; pub mod agent_skill_v2; -pub mod app; pub mod artifact; pub mod attachment; pub mod automation; @@ -46,8 +44,6 @@ pub mod memory; pub mod models; pub mod orchestrator; pub mod permission; -pub mod plugin; -pub mod plugin_marketplace; pub mod project; pub mod provider; pub mod provider_lifecycle; @@ -66,6 +62,7 @@ pub mod store; pub mod task; pub mod task_capsule; pub mod task_store; +#[cfg(feature = "terminal")] pub mod term; pub mod testsignal; pub mod tmux; @@ -189,6 +186,7 @@ pub use task_capsule::{ TaskCapsuleError, }; pub use task_store::TaskRecord; +#[cfg(feature = "terminal")] pub use term::{Scope, TerminalConfig, TerminalHandle, TerminalOutput}; pub use testsignal::{classify_test_command, test_outcome, TestOutcome}; pub use workspace_search::{WorkspaceContentMatch, WorkspaceSearchOptions, WorkspaceSearchResult}; diff --git a/crates/core/src/memory.rs b/crates/core/src/memory.rs index 843fdd25..44666981 100644 --- a/crates/core/src/memory.rs +++ b/crates/core/src/memory.rs @@ -280,10 +280,6 @@ pub struct MemoryCapability { inner: Arc, } -impl codetwo_kernel::Service for MemoryCapability { - const NAME: &'static str = "memory"; -} - struct MemoryCapabilityInner { store: Arc, lifecycle: RwLock, diff --git a/crates/core/tests/architecture_boundary.rs b/crates/core/tests/architecture_boundary.rs new file mode 100644 index 00000000..7cf8466d --- /dev/null +++ b/crates/core/tests/architecture_boundary.rs @@ -0,0 +1,64 @@ +use std::collections::BTreeSet; + +fn dependencies(manifest: &str) -> BTreeSet { + fn collect(value: &toml::Value, names: &mut BTreeSet) { + let Some(table) = value.as_table() else { + return; + }; + for (name, value) in table { + if matches!( + name.as_str(), + "dependencies" | "dev-dependencies" | "build-dependencies" + ) { + if let Some(dependencies) = value.as_table() { + names.extend(dependencies.keys().cloned()); + } + } else { + collect(value, names); + } + } + } + + let document = manifest + .parse::() + .expect("valid Cargo manifest"); + let mut names = BTreeSet::new(); + collect(&document, &mut names); + names +} + +#[test] +fn core_does_not_depend_on_plugin_runtime() { + let core_dependencies = dependencies(include_str!("../Cargo.toml")); + + for forbidden in ["codetwo-kernel", "codetwo-plugins"] { + assert!( + !core_dependencies.contains(forbidden), + "codetwo-core must not depend on {forbidden}" + ); + } + + let core_root = include_str!("../src/lib.rs"); + for moved_module in [ + "pub mod app;", + "pub mod plugin;", + "pub mod plugin_marketplace;", + ] { + assert!( + !core_root.lines().any(|line| line.trim() == moved_module), + "{moved_module} belongs in codetwo-plugins" + ); + } +} + +#[test] +fn plugins_is_the_composition_root() { + let plugin_dependencies = dependencies(include_str!("../../plugins/Cargo.toml")); + + for required in ["codetwo-core", "codetwo-kernel"] { + assert!( + plugin_dependencies.contains(required), + "codetwo-plugins must compose {required}" + ); + } +} diff --git a/crates/plugins/Cargo.toml b/crates/plugins/Cargo.toml new file mode 100644 index 00000000..ce27c047 --- /dev/null +++ b/crates/plugins/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "codetwo-plugins" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +description = "C2 runtime-module graph, built-in adapters, extension bundles, and plugin protocol." + +[dependencies] +codetwo-core = { workspace = true, features = ["terminal"] } +codetwo-kernel.workspace = true +tokio.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +async-trait.workspace = true +uuid.workspace = true +tracing.workspace = true +blake3 = "1" +base64 = "0.22" +chrono = "0.4" +url = "2" +tempfile = "3" +notify = "8.2" + +[dev-dependencies] +rusqlite = { version = "0.32", features = ["bundled"] } diff --git a/crates/core/examples/validate_bundle.rs b/crates/plugins/examples/validate_bundle.rs similarity index 86% rename from crates/core/examples/validate_bundle.rs rename to crates/plugins/examples/validate_bundle.rs index 88620f6e..76a5d81b 100644 --- a/crates/core/examples/validate_bundle.rs +++ b/crates/plugins/examples/validate_bundle.rs @@ -8,7 +8,7 @@ fn main() -> ExitCode { }; let bundle = - match codetwo_core::plugin::from_local(Path::new(&path), "Bundle validation", &path) { + match codetwo_plugins::bundle::from_local(Path::new(&path), "Bundle validation", &path) { Ok(bundle) => bundle, Err(error) => { eprintln!("invalid bundle: {error}"); diff --git a/crates/core/examples/validate_marketplace.rs b/crates/plugins/examples/validate_marketplace.rs similarity index 92% rename from crates/core/examples/validate_marketplace.rs rename to crates/plugins/examples/validate_marketplace.rs index 08fccf42..d007fad7 100644 --- a/crates/core/examples/validate_marketplace.rs +++ b/crates/plugins/examples/validate_marketplace.rs @@ -7,7 +7,7 @@ fn main() -> ExitCode { return ExitCode::from(2); }; - let marketplace = match codetwo_core::plugin_marketplace::load(Path::new(&path)) { + let marketplace = match codetwo_plugins::marketplace::load(Path::new(&path)) { Ok(marketplace) => marketplace, Err(error) => { eprintln!("invalid marketplace: {error}"); diff --git a/crates/core/schemas/agent-plugins/1.0.0/mcp.schema.json b/crates/plugins/schemas/agent-plugins/1.0.0/mcp.schema.json similarity index 100% rename from crates/core/schemas/agent-plugins/1.0.0/mcp.schema.json rename to crates/plugins/schemas/agent-plugins/1.0.0/mcp.schema.json diff --git a/crates/core/schemas/agent-plugins/1.0.0/plugin.schema.json b/crates/plugins/schemas/agent-plugins/1.0.0/plugin.schema.json similarity index 100% rename from crates/core/schemas/agent-plugins/1.0.0/plugin.schema.json rename to crates/plugins/schemas/agent-plugins/1.0.0/plugin.schema.json diff --git a/crates/core/src/app/bundle_runtime.rs b/crates/plugins/src/app/bundle_runtime.rs similarity index 99% rename from crates/core/src/app/bundle_runtime.rs rename to crates/plugins/src/app/bundle_runtime.rs index 3a82cedb..ff1ac6dd 100644 --- a/crates/core/src/app/bundle_runtime.rs +++ b/crates/plugins/src/app/bundle_runtime.rs @@ -1,5 +1,5 @@ use super::{normalize_project_path, protocol::ProtocolPlugin}; -use crate::plugin::{InstalledPlugin, PluginRuntimeCommand, PluginRuntimeSpec}; +use crate::bundle::{InstalledPlugin, PluginRuntimeCommand, PluginRuntimeSpec}; use codetwo_kernel::{ async_trait, CommandRealm, Context, Injection, Plugin, PluginCategory, PluginEntry, PluginError, PluginMetadata, PluginOrigin, PluginRegistry, PluginResult, PluginRole, Service, diff --git a/crates/core/src/app/events.rs b/crates/plugins/src/app/events.rs similarity index 97% rename from crates/core/src/app/events.rs rename to crates/plugins/src/app/events.rs index 55ef9749..ce3ae642 100644 --- a/crates/core/src/app/events.rs +++ b/crates/plugins/src/app/events.rs @@ -56,7 +56,7 @@ impl Event for WorkspaceChanged { /// [`crate::app::EventBus`] carries the same stream over a broadcast channel for consumers that /// want a receiver (the desktop event pump, the remote server). This carries it to *plugins*, so a /// listener is owned by a scope and disappears when that plugin unloads. -pub struct EngineEvent(pub crate::event::Event); +pub struct EngineEvent(pub codetwo_core::event::Event); impl Event for EngineEvent { type Output = (); diff --git a/crates/core/src/app/mod.rs b/crates/plugins/src/app/mod.rs similarity index 93% rename from crates/core/src/app/mod.rs rename to crates/plugins/src/app/mod.rs index fe286264..7f6612b6 100644 --- a/crates/core/src/app/mod.rs +++ b/crates/plugins/src/app/mod.rs @@ -44,7 +44,7 @@ //! # Booting //! //! ```no_run -//! # use codetwo_core::app::{AppConfig, CoreApp}; +//! # use codetwo_plugins::{AppConfig, CoreApp}; //! # async fn demo() -> Result<(), Box> { //! let app = CoreApp::boot(AppConfig::new("/home/me/.codetwo")).await?; //! let status = app.call("git.status", serde_json::json!({ "cwd": "/repo" })).await?; @@ -72,7 +72,7 @@ pub use plugin_manager::{ pub use service::{ CanvasService, CostService, EngineService, EventBus, HandoffService, KeymapService, - LoaderService, Paths, PluginConfigService, PluginHub, ProviderService, + LoaderService, MemoryService, Paths, PluginConfigService, PluginHub, ProviderService, ProviderSummary, SceneRuntimeService, SceneService, SkillService, StoreService, TerminalEvent, TerminalService, }; @@ -304,27 +304,6 @@ impl CoreApp { }) } - /// The root context — load your own plugins into this. - pub fn ctx(&self) -> Context { - self.app.ctx() - } - - pub fn kernel(&self) -> &App { - &self.app - } - - pub fn loader(&self) -> &Arc> { - &self.loader - } - - pub fn plugin_config(&self) -> &Arc> { - &self.plugin_config - } - - pub fn plugin_manager(&self) -> &Arc { - &self.plugin_manager - } - /// Invoke a command through the trusted host transport. /// /// This includes internal commands and is deliberately broader than the public Extension API. @@ -390,6 +369,40 @@ impl CoreApp { } } +/// Internal handles used by integration tests that exercise graph mutation and recovery. +/// +/// Keeping them behind an explicitly imported testing trait prevents the normal `CoreApp` +/// interface from exposing Kernel implementation objects to hosts. +#[doc(hidden)] +pub mod testing { + use super::*; + + pub trait CoreAppTestExt { + fn ctx(&self) -> Context; + fn loader(&self) -> &Arc>; + fn plugin_config(&self) -> &Arc>; + fn plugin_manager(&self) -> &Arc; + } + + impl CoreAppTestExt for CoreApp { + fn ctx(&self) -> Context { + self.app.ctx() + } + + fn loader(&self) -> &Arc> { + &self.loader + } + + fn plugin_config(&self) -> &Arc> { + &self.plugin_config + } + + fn plugin_manager(&self) -> &Arc { + &self.plugin_manager + } + } +} + /// The smallest graph that can keep every management-plane plugin alive in safe mode. /// /// Required injection names that also name a registered factory are plugin dependencies and are diff --git a/crates/core/src/app/plugin_config.rs b/crates/plugins/src/app/plugin_config.rs similarity index 100% rename from crates/core/src/app/plugin_config.rs rename to crates/plugins/src/app/plugin_config.rs diff --git a/crates/core/src/app/plugin_manager.rs b/crates/plugins/src/app/plugin_manager.rs similarity index 99% rename from crates/core/src/app/plugin_manager.rs rename to crates/plugins/src/app/plugin_manager.rs index 239078c5..523bffa6 100644 --- a/crates/core/src/app/plugin_manager.rs +++ b/crates/plugins/src/app/plugin_manager.rs @@ -8,7 +8,7 @@ use super::{ PluginConfigError, PluginConfigStore, PluginOverride, PluginPolicy, PluginRecoveryState, PluginScope, }; -use crate::plugin; +use crate::bundle as plugin; use codetwo_kernel::{ events::StatusChanged, CommandRealm, Context, FnPlugin, Fork, Injection, KernelError, Loader, LoaderConfig, PluginEntry, PluginMetadata, PluginRegistry, PluginRole, PluginScopeSupport, diff --git a/crates/core/src/app/plugins/canvas.rs b/crates/plugins/src/app/plugins/canvas.rs similarity index 93% rename from crates/core/src/app/plugins/canvas.rs rename to crates/plugins/src/app/plugins/canvas.rs index 76ed5a6d..dc1e0256 100644 --- a/crates/core/src/app/plugins/canvas.rs +++ b/crates/plugins/src/app/plugins/canvas.rs @@ -2,12 +2,12 @@ use crate::app::service::{CanvasService, EngineService, Paths, SkillService, StoreService}; use crate::app::{json, take_args}; -use crate::canvas::{ +use codetwo_core::canvas::{ CanvasAssetRef, CanvasDraft, CanvasDraftUpdate, CanvasExport, CanvasFeatureGate, CanvasFreezeInput, CanvasManifest, CanvasObject, CanvasSceneEnvelope, CanvasSnapshot, CanvasStaticAsset, }; -use crate::skill::DocBlock; +use codetwo_core::skill::DocBlock; use codetwo_kernel::{async_trait, Context, Injection, Plugin, PluginError, PluginResult}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -46,7 +46,7 @@ impl Plugin for CanvasPlugin { fn register_canvas_commands(ctx: &Context, canvas: Arc) -> PluginResult { ctx.command("canvas.feature_state", |_| async move { json(CanvasFeatureStateDto { - feature: crate::canvas::CANVAS_FEATURE_GATE, + feature: codetwo_core::canvas::CANVAS_FEATURE_GATE, enabled: false, status: "not production-enabled", }) @@ -69,7 +69,7 @@ fn register_canvas_commands(ctx: &Context, canvas: Arc) -> Plugin canvas.gate, &canvas.owner, &args.title, - crate::session::now_millis(), + codetwo_core::session::now_millis(), ) .map_err(PluginError::new)?, )) @@ -116,7 +116,7 @@ fn register_canvas_commands(ctx: &Context, canvas: Arc) -> Plugin &canvas.owner, args.expected_revision, args.update, - crate::session::now_millis(), + codetwo_core::session::now_millis(), ) .map_err(PluginError::new)?, )) @@ -136,7 +136,7 @@ fn register_canvas_commands(ctx: &Context, canvas: Arc) -> Plugin let args: MediaArgs = take_args(args)?; require_canvas(&canvas)?; json(CanvasAssetDto::from( - crate::canvas::normalize_media(&args.bytes, args.declared_mime.as_deref()) + codetwo_core::canvas::normalize_media(&args.bytes, args.declared_mime.as_deref()) .map_err(PluginError::new)?, )) } @@ -162,7 +162,7 @@ fn register_canvas_commands(ctx: &Context, canvas: Arc) -> Plugin &args.id, &canvas.owner, args.expected_revision, - args.input.into_core(crate::session::now_millis()), + args.input.into_core(codetwo_core::session::now_millis()), ) .map_err(PluginError::new)?, )) @@ -262,7 +262,7 @@ fn register_canvas_commands(ctx: &Context, canvas: Arc) -> Plugin &args.id, args.revision, &canvas.owner, - crate::session::now_millis(), + codetwo_core::session::now_millis(), ) .map_err(PluginError::new)?, )) @@ -277,7 +277,7 @@ fn register_canvas_commands(ctx: &Context, canvas: Arc) -> Plugin require_canvas(&canvas)?; canvas .store - .tombstone_canvas(&args.id, &canvas.owner, crate::session::now_millis()) + .tombstone_canvas(&args.id, &canvas.owner, codetwo_core::session::now_millis()) .map_err(PluginError::new)?; Ok(Value::Bool(true)) } @@ -291,7 +291,7 @@ fn register_canvas_commands(ctx: &Context, canvas: Arc) -> Plugin require_canvas(&canvas)?; canvas .store - .restore_canvas(&args.id, &canvas.owner, crate::session::now_millis()) + .restore_canvas(&args.id, &canvas.owner, codetwo_core::session::now_millis()) .map_err(PluginError::new)?; Ok(Value::Bool(true)) } @@ -305,7 +305,7 @@ fn register_canvas_commands(ctx: &Context, canvas: Arc) -> Plugin json( canvas .store - .purge_canvas(&args.id, &canvas.owner, crate::session::now_millis()) + .purge_canvas(&args.id, &canvas.owner, codetwo_core::session::now_millis()) .map_err(PluginError::new)?, ) } @@ -359,13 +359,13 @@ impl Plugin for DocumentPlugin { .ok() .flatten() }; - let compiled = crate::skill::compile_with_canvas( + let compiled = codetwo_core::skill::compile_with_canvas( &args.doc, &library, path, Some(&resolve), canvas.gate, - crate::canvas::CanvasProviderImageCapability::Unknown, + codetwo_core::canvas::CanvasProviderImageCapability::Unknown, &|id, revision| store.resolve_canvas_prompt_frozen(id, revision), ) .map_err(PluginError::new)?; @@ -455,7 +455,7 @@ struct CanvasEnvelopeDto { engine_version: String, schema_version: u32, revision: u64, - theme: crate::canvas::CanvasTheme, + theme: codetwo_core::canvas::CanvasTheme, assets: Vec, scene: Value, } @@ -478,12 +478,12 @@ impl From for CanvasEnvelopeDto { #[serde(rename_all = "camelCase")] struct CanvasObjectDto { id: String, - kind: crate::canvas::CanvasObjectKind, + kind: codetwo_core::canvas::CanvasObjectKind, original_text: String, - bounds: crate::canvas::CanvasRect, + bounds: codetwo_core::canvas::CanvasRect, layer: i64, - arrow_start: Option, - arrow_end: Option, + arrow_start: Option, + arrow_end: Option, asset_id: Option, } @@ -519,7 +519,7 @@ impl From for CanvasManifestDto { #[serde(rename_all = "camelCase")] struct CanvasExportDto { id: String, - kind: crate::canvas::CanvasExportKind, + kind: codetwo_core::canvas::CanvasExportKind, index: Option, mime_type: String, width: u32, @@ -548,7 +548,7 @@ struct CanvasDraftDto { owner: String, revision: u64, title: String, - theme: crate::canvas::CanvasTheme, + theme: codetwo_core::canvas::CanvasTheme, envelope: CanvasEnvelopeDto, manifest: CanvasManifestDto, assets: Vec, @@ -581,7 +581,7 @@ struct CanvasSnapshotDto { id: String, revision: u64, title: String, - theme: crate::canvas::CanvasTheme, + theme: codetwo_core::canvas::CanvasTheme, created_at: i64, frozen_at: i64, object_count: usize, @@ -614,7 +614,7 @@ impl From for CanvasSnapshotDto { #[derive(Deserialize)] struct CanvasFreezeCommandInput { title: String, - theme: crate::canvas::CanvasTheme, + theme: codetwo_core::canvas::CanvasTheme, envelope: CanvasSceneEnvelope, manifest: CanvasManifest, #[serde(default)] diff --git a/crates/core/src/app/plugins/engine.rs b/crates/plugins/src/app/plugins/engine.rs similarity index 92% rename from crates/core/src/app/plugins/engine.rs rename to crates/plugins/src/app/plugins/engine.rs index 46831c21..3f6cdb35 100644 --- a/crates/core/src/app/plugins/engine.rs +++ b/crates/plugins/src/app/plugins/engine.rs @@ -14,13 +14,13 @@ use crate::app::service::{ EngineService, EventBus, Paths, ProviderService, SceneService, SkillService, StoreService, }; use crate::app::{json, take_args}; -use crate::engine::{Engine, ParallelTaskCreation}; -use crate::event::Op; -use crate::permission::{ExecutionPolicy, PermissionMode, SandboxPolicy}; -use crate::provider::ProviderId; -use crate::session::TranscriptCursor; -use crate::task::TaskId; -use crate::worktree::WorktreeBaseline; +use codetwo_core::engine::{Engine, ParallelTaskCreation}; +use codetwo_core::event::Op; +use codetwo_core::permission::{ExecutionPolicy, PermissionMode, SandboxPolicy}; +use codetwo_core::provider::ProviderId; +use codetwo_core::session::TranscriptCursor; +use codetwo_core::task::TaskId; +use codetwo_core::worktree::WorktreeBaseline; use codetwo_kernel::{async_trait, Context, Injection, Plugin, PluginError, PluginResult}; use serde::Deserialize; use serde_json::Value; @@ -30,19 +30,22 @@ use std::sync::Mutex; #[derive(Clone)] struct QueuedPrompt { - doc: Vec, + doc: Vec, request_id: Option, } /// What the engine is built from. A host that needs a different construction gets these and /// returns an engine without forking the plugin graph. pub struct EngineInputs { - pub providers: Vec, - pub provider_tools: - Arc>>, - pub skills: crate::skill::SkillLibrary, - pub store: Arc, - pub memory: Option, + pub providers: Vec, + pub provider_tools: Arc< + std::sync::RwLock< + std::collections::HashMap, + >, + >, + pub skills: codetwo_core::skill::SkillLibrary, + pub store: Arc, + pub memory: Option, } /// Replaces how the engine is constructed, without replacing what it is wired to. @@ -51,7 +54,7 @@ pub type EngineBuilder = Arc< EngineInputs, ) -> ( Engine, - tokio::sync::mpsc::UnboundedReceiver, + tokio::sync::mpsc::UnboundedReceiver, ) + Send + Sync, >; @@ -124,8 +127,8 @@ impl Plugin for EnginePlugin { skills: skills.library(), store: store.0.clone(), memory: ctx - .get::() - .map(|memory| memory.as_ref().clone()), + .get::() + .map(|memory| memory.0.clone()), }; let (engine, mut rx) = match &self.builder { Some(build) => build(inputs), @@ -138,10 +141,10 @@ impl Plugin for EnginePlugin { ), }; engine.set_private_data_dir(paths.data_dir.clone()); - let worktree_settings = - crate::worktree::load_settings(&paths.data_dir).unwrap_or_else(|error| { + let worktree_settings = codetwo_core::worktree::load_settings(&paths.data_dir) + .unwrap_or_else(|error| { tracing::warn!("could not load worktree settings: {error}"); - crate::worktree::WorktreeSettings::default() + codetwo_core::worktree::WorktreeSettings::default() }); engine.set_worktree_root(worktree_settings.root.map(std::path::PathBuf::from)); let engine = Arc::new(engine); @@ -308,7 +311,7 @@ fn register_commands( &args.session, args.before, args.limit - .unwrap_or(crate::session::DEFAULT_TRANSCRIPT_TURNS), + .unwrap_or(codetwo_core::session::DEFAULT_TRANSCRIPT_TURNS), ) .map_err(PluginError::new)?, ) @@ -381,13 +384,13 @@ fn register_commands( "select no more than 100 session files at once", )); } - let report = crate::session_import::import_session_files( + let report = codetwo_core::session_import::import_session_files( &store, &args.paths, &args.fallback_cwd, ); for session in report.sessions.iter().filter(|session| session.imported) { - bus.publish(crate::event::Event::SessionCreated { + bus.publish(codetwo_core::event::Event::SessionCreated { session: session.id.clone(), cwd: session.cwd.clone(), project_path: session.project_path.clone(), @@ -417,7 +420,7 @@ fn register_commands( return Ok(Value::Null); }; json( - crate::git::diff_stat(std::path::Path::new(&cwd)) + codetwo_core::git::diff_stat(std::path::Path::new(&cwd)) .await .map_err(PluginError::new)?, ) @@ -453,7 +456,7 @@ fn register_commands( async move { let mut args: NewSessionArgs = take_args(args)?; let worktree_settings = - crate::worktree::load_settings(&settings_dir).map_err(PluginError::new)?; + codetwo_core::worktree::load_settings(&settings_dir).map_err(PluginError::new)?; engine.set_worktree_root( worktree_settings .root @@ -462,12 +465,12 @@ fn register_commands( ); if args.use_worktree && worktree_settings.fetch_upstream { let source = std::path::Path::new(&args.cwd); - crate::worktree::fetch_upstream(source) + codetwo_core::worktree::fetch_upstream(source) .await .map_err(PluginError::new)?; let baseline = args.worktree_base.unwrap_or(WorktreeBaseline::Current); args.worktree_base_sha = Some( - crate::worktree::resolve_baseline(source, baseline) + codetwo_core::worktree::resolve_baseline(source, baseline) .await .map_err(PluginError::new)? .sha, @@ -526,7 +529,7 @@ fn register_commands( async move { let mut args: NewParallelTaskArgs = take_args(args)?; let worktree_settings = - crate::worktree::load_settings(&settings_dir).map_err(PluginError::new)?; + codetwo_core::worktree::load_settings(&settings_dir).map_err(PluginError::new)?; engine.set_worktree_root( worktree_settings .root @@ -536,11 +539,11 @@ fn register_commands( let baseline = args.worktree_base.unwrap_or(WorktreeBaseline::Current); if worktree_settings.fetch_upstream { let source = std::path::Path::new(&args.cwd); - crate::worktree::fetch_upstream(source) + codetwo_core::worktree::fetch_upstream(source) .await .map_err(PluginError::new)?; args.worktree_base_sha = Some( - crate::worktree::resolve_baseline(source, baseline) + codetwo_core::worktree::resolve_baseline(source, baseline) .await .map_err(PluginError::new)? .sha, @@ -576,7 +579,7 @@ fn register_commands( #[derive(Deserialize)] struct PromptArgs { session: String, - doc: Vec, + doc: Vec, #[serde(default)] request_id: Option, } @@ -637,7 +640,7 @@ fn register_commands( let queues = queues.clone(); async move { let args: PromptArgs = take_args(args)?; - if crate::skill::canonical_doc_text(&args.doc) + if codetwo_core::skill::canonical_doc_text(&args.doc) .trim() .is_empty() { @@ -663,7 +666,7 @@ fn register_commands( }); queue.len() }; - bus.publish(crate::event::Event::PromptQueued { + bus.publish(codetwo_core::event::Event::PromptQueued { session: args.session, request_id: args.request_id, position, @@ -716,8 +719,8 @@ fn register_commands( Err(tokio::sync::broadcast::error::RecvError::Closed) => break, }; let session = match &event { - crate::event::Event::TurnEnded { session, .. } => Some(session.clone()), - crate::event::Event::Error { + codetwo_core::event::Event::TurnEnded { session, .. } => Some(session.clone()), + codetwo_core::event::Event::Error { session: Some(session), terminal: true, .. @@ -741,7 +744,7 @@ fn register_commands( (next, remaining) }; for (index, queued) in remaining.into_iter().enumerate() { - draining_bus.publish(crate::event::Event::PromptQueued { + draining_bus.publish(codetwo_core::event::Event::PromptQueued { session: session.clone(), request_id: queued.request_id, position: index + 1, @@ -783,7 +786,7 @@ fn register_commands( struct ElicitationArgs { session: String, request_id: String, - answer: crate::elicitation::ElicitationAnswer, + answer: codetwo_core::elicitation::ElicitationAnswer, } let elicitation = engine.clone(); ctx.command("engine.answer_elicitation", move |args| { @@ -943,16 +946,13 @@ fn register_commands( ctx.command("worktrees.settings", move |_| { let settings_dir = settings_dir.clone(); async move { - json( - crate::worktree::load_settings(&settings_dir) - .map_err(PluginError::new)?, - ) + json(codetwo_core::worktree::load_settings(&settings_dir).map_err(PluginError::new)?) } })?; #[derive(Deserialize)] struct WorktreeSettingsArgs { - settings: crate::worktree::WorktreeSettings, + settings: codetwo_core::worktree::WorktreeSettings, } let settings_dir = paths.data_dir.clone(); let settings_engine = engine.clone(); @@ -961,7 +961,7 @@ fn register_commands( let engine = settings_engine.clone(); async move { let args: WorktreeSettingsArgs = take_args(args)?; - let settings = crate::worktree::save_settings(&settings_dir, args.settings) + let settings = codetwo_core::worktree::save_settings(&settings_dir, args.settings) .map_err(PluginError::new)?; engine.set_worktree_root(settings.root.as_deref().map(std::path::PathBuf::from)); if settings.auto_delete { diff --git a/crates/core/src/app/plugins/extensions.rs b/crates/plugins/src/app/plugins/extensions.rs similarity index 100% rename from crates/core/src/app/plugins/extensions.rs rename to crates/plugins/src/app/plugins/extensions.rs diff --git a/crates/core/src/app/plugins/foundation.rs b/crates/plugins/src/app/plugins/foundation.rs similarity index 98% rename from crates/core/src/app/plugins/foundation.rs rename to crates/plugins/src/app/plugins/foundation.rs index 014b3a41..78d1854c 100644 --- a/crates/core/src/app/plugins/foundation.rs +++ b/crates/plugins/src/app/plugins/foundation.rs @@ -7,10 +7,10 @@ use crate::app::service::{EventBus, Paths, ProviderService, StoreService}; use crate::app::{json, take_args}; -use crate::host_tools::HostToolDiscovery; -use crate::provider::default_registry; -use crate::provider_lifecycle::{ProviderLifecycleAction, ProviderLifecycleManager}; -use crate::store::Store; +use codetwo_core::host_tools::HostToolDiscovery; +use codetwo_core::provider::default_registry; +use codetwo_core::provider_lifecycle::{ProviderLifecycleAction, ProviderLifecycleManager}; +use codetwo_core::store::Store; use codetwo_kernel::{async_trait, Context, Injection, Plugin, PluginError, PluginResult}; use serde::Deserialize; use serde_json::{json as jval, Value}; diff --git a/crates/core/src/app/plugins/handoff.rs b/crates/plugins/src/app/plugins/handoff.rs similarity index 98% rename from crates/core/src/app/plugins/handoff.rs rename to crates/plugins/src/app/plugins/handoff.rs index 2f1da021..97e52683 100644 --- a/crates/core/src/app/plugins/handoff.rs +++ b/crates/plugins/src/app/plugins/handoff.rs @@ -8,7 +8,7 @@ use serde_json::Value; use crate::app::service::{EngineService, EventBus, HandoffService, StoreService}; use crate::app::{json, take_args}; -use crate::handoff::{PortableTaskHandoff, TaskHandoffManager}; +use codetwo_core::handoff::{PortableTaskHandoff, TaskHandoffManager}; pub struct HandoffPlugin; diff --git a/crates/core/src/app/plugins/hub.rs b/crates/plugins/src/app/plugins/hub.rs similarity index 99% rename from crates/core/src/app/plugins/hub.rs rename to crates/plugins/src/app/plugins/hub.rs index dfb4a0d4..955478c1 100644 --- a/crates/core/src/app/plugins/hub.rs +++ b/crates/plugins/src/app/plugins/hub.rs @@ -12,10 +12,10 @@ use crate::app::events::PluginsChanged; use crate::app::plugins::plugin_development; use crate::app::service::{LoaderService, Paths, PluginHub}; use crate::app::{json, take_args, PluginChangeRequest, PluginManager, PluginScope}; -use crate::github_skills; -use crate::plugin; -use crate::plugin::{InstalledPlugin, PluginCounts, PluginScaffold}; -use crate::plugin_marketplace::{self, MarketplacePluginSource}; +use crate::bundle as plugin; +use crate::bundle::{InstalledPlugin, PluginCounts, PluginScaffold}; +use crate::marketplace::{self as plugin_marketplace, MarketplacePluginSource}; +use codetwo_core::github_skills; use codetwo_kernel::{ async_trait, CommandRealm, Context, Injection, Plugin, PluginError, PluginResult, PluginScopeSupport, WeakContext, diff --git a/crates/core/src/app/plugins/issues.rs b/crates/plugins/src/app/plugins/issues.rs similarity index 97% rename from crates/core/src/app/plugins/issues.rs rename to crates/plugins/src/app/plugins/issues.rs index 7d9be3e6..fd8fb616 100644 --- a/crates/core/src/app/plugins/issues.rs +++ b/crates/plugins/src/app/plugins/issues.rs @@ -2,8 +2,8 @@ use crate::app::service::StoreService; use crate::app::{json, take_args}; -use crate::issues::{self, Issue}; -use crate::skill::SlotDef; +use codetwo_core::issues::{self, Issue}; +use codetwo_core::skill::SlotDef; use codetwo_kernel::{async_trait, Context, Injection, Plugin, PluginError, PluginResult}; use serde::Deserialize; use serde_json::Value; @@ -107,7 +107,7 @@ impl Plugin for IssuesPlugin { } ctx.command("issues.structure_brief", |args| async move { let args: BriefArgs = take_args(args)?; - json(crate::brief::structure_brief_heuristic( + json(codetwo_core::brief::structure_brief_heuristic( &args.transcript, &args.slots, )) diff --git a/crates/core/src/app/plugins/library.rs b/crates/plugins/src/app/plugins/library.rs similarity index 94% rename from crates/core/src/app/plugins/library.rs rename to crates/plugins/src/app/plugins/library.rs index b65fb90f..dae09181 100644 --- a/crates/core/src/app/plugins/library.rs +++ b/crates/plugins/src/app/plugins/library.rs @@ -12,10 +12,10 @@ use crate::app::service::{ Paths, PluginConfigService, PluginHub, SceneService, SkillService, StoreService, }; use crate::app::{json, take_args}; -use crate::artifact::ArtifactStore; -use crate::scene::{Pipeline, Scene, SceneLibrary, SceneSource}; -use crate::scene_artifact::SceneArtifactStore; -use crate::skill::{Skill, SkillKind, SkillPayload, SlotDef}; +use codetwo_core::artifact::ArtifactStore; +use codetwo_core::scene::{Pipeline, Scene, SceneLibrary, SceneSource}; +use codetwo_core::scene_artifact::SceneArtifactStore; +use codetwo_core::skill::{Skill, SkillKind, SkillPayload, SlotDef}; use codetwo_kernel::{async_trait, Context, Injection, Plugin, PluginResult}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -39,16 +39,16 @@ struct SceneInfo { plugin_id: Option, keywords: Vec, has_brief: bool, - localizations: std::collections::HashMap, - execution: Option, - brief: Option, - artifacts: Vec, - skills: Option, - exit: Option, + localizations: std::collections::HashMap, + execution: Option, + brief: Option, + artifacts: Vec, + skills: Option, + exit: Option, } impl SceneInfo { - fn from_resolved(entry: &crate::scene::ResolvedScene) -> Self { + fn from_resolved(entry: &codetwo_core::scene::ResolvedScene) -> Self { let plugin_id = match &entry.source { SceneSource::Plugin { plugin_id } => Some(plugin_id.clone()), _ => None, @@ -145,7 +145,7 @@ impl SkillInfo { source: skill .source .clone() - .or_else(|| crate::harness::source_label(&skill.id).map(str::to_string)), + .or_else(|| codetwo_core::harness::source_label(&skill.id).map(str::to_string)), macro_template, macro_slots, } @@ -263,7 +263,7 @@ impl Plugin for SkillsPlugin { } ctx.command("skills.propose_macro", move |args| async move { let args: ProposeArgs = take_args(args)?; - let (template, slots) = crate::skill::propose_macro_slots(&args.text); + let (template, slots) = codetwo_core::skill::propose_macro_slots(&args.text); json(ProposedMacro { template, slots }) })?; @@ -503,7 +503,7 @@ impl Plugin for ScenesPlugin { let entry = library.resolve(&args.reference).ok_or_else(|| { codetwo_kernel::PluginError::new(format!("unknown scene `{}`", args.reference)) })?; - json(crate::scene::export_skill_md(&entry.scene)) + json(codetwo_core::scene::export_skill_md(&entry.scene)) } })?; @@ -550,7 +550,7 @@ fn editable_scene_dir( cwd: Option<&str>, ) -> Result { match scope { - SceneSaveScope::User => crate::provider::home_dir() + SceneSaveScope::User => codetwo_core::provider::home_dir() .map(|home| home.join(".config/codetwo/scenes")) .ok_or_else(|| codetwo_kernel::PluginError::new("home directory is unavailable")), SceneSaveScope::Project => cwd diff --git a/crates/core/src/app/plugins/memory.rs b/crates/plugins/src/app/plugins/memory.rs similarity index 97% rename from crates/core/src/app/plugins/memory.rs rename to crates/plugins/src/app/plugins/memory.rs index 5e11e1b9..e2989296 100644 --- a/crates/core/src/app/plugins/memory.rs +++ b/crates/plugins/src/app/plugins/memory.rs @@ -5,10 +5,10 @@ //! unavailable" — which is what the old wrappers had to say, nine times, because nothing in the //! type system stopped them from being called without one. -use crate::app::service::StoreService; +use crate::app::service::{MemoryService, StoreService}; use crate::app::{json, take_args}; -use crate::memory::{MemoryCapability, MemoryProjectPolicy, MemorySettings}; -use crate::session::MemoryAccess; +use codetwo_core::memory::{MemoryCapability, MemoryProjectPolicy, MemorySettings}; +use codetwo_core::session::MemoryAccess; use codetwo_kernel::{async_trait, Context, Injection, Plugin, PluginError, PluginResult}; use serde::Deserialize; use serde_json::Value; @@ -41,7 +41,7 @@ impl Plugin for MemoryPlugin { let store = ctx.expect::()?; let memory = MemoryCapability::new(store.0.clone()); memory.catch_up().map_err(PluginError::new)?; - ctx.provide(Arc::new(memory.clone()))?; + ctx.provide(Arc::new(MemoryService(memory.clone())))?; ctx.effect(move || memory.deactivate()); let read = store.clone(); diff --git a/crates/core/src/app/plugins/mod.rs b/crates/plugins/src/app/plugins/mod.rs similarity index 100% rename from crates/core/src/app/plugins/mod.rs rename to crates/plugins/src/app/plugins/mod.rs diff --git a/crates/core/src/app/plugins/plugin_development.rs b/crates/plugins/src/app/plugins/plugin_development.rs similarity index 99% rename from crates/core/src/app/plugins/plugin_development.rs rename to crates/plugins/src/app/plugins/plugin_development.rs index 96a5ba61..5856cc75 100644 --- a/crates/core/src/app/plugins/plugin_development.rs +++ b/crates/plugins/src/app/plugins/plugin_development.rs @@ -316,7 +316,7 @@ fn record_reload( Err(error) => (false, Some(error)), }; status.lock().unwrap().last_reload = Some(PluginReloadRecord { - at: crate::session::now_millis(), + at: codetwo_core::session::now_millis(), plugins, success, error, diff --git a/crates/core/src/app/plugins/runtime.rs b/crates/plugins/src/app/plugins/runtime.rs similarity index 98% rename from crates/core/src/app/plugins/runtime.rs rename to crates/plugins/src/app/plugins/runtime.rs index 16ade3d7..a7f096be 100644 --- a/crates/core/src/app/plugins/runtime.rs +++ b/crates/plugins/src/app/plugins/runtime.rs @@ -10,10 +10,10 @@ use crate::app::service::{ CostService, EngineService, EventBus, SceneRuntimeService, SceneService, StoreService, }; use crate::app::{json, take_args}; -use crate::cost::SessionCostTracker; -use crate::event::Event; -use crate::scene_runtime::SceneRuntime; -use crate::store::Store; +use codetwo_core::cost::SessionCostTracker; +use codetwo_core::event::Event; +use codetwo_core::scene_runtime::SceneRuntime; +use codetwo_core::store::Store; use codetwo_kernel::{async_trait, Context, Injection, Plugin, PluginError, PluginResult}; use serde::Deserialize; use serde_json::Value; diff --git a/crates/core/src/app/plugins/scene_commands.rs b/crates/plugins/src/app/plugins/scene_commands.rs similarity index 98% rename from crates/core/src/app/plugins/scene_commands.rs rename to crates/plugins/src/app/plugins/scene_commands.rs index 8216c203..c6aad5d5 100644 --- a/crates/core/src/app/plugins/scene_commands.rs +++ b/crates/plugins/src/app/plugins/scene_commands.rs @@ -4,11 +4,13 @@ use crate::app::service::{ EngineService, ProviderService, SceneRuntimeService, SceneService, StoreService, }; use crate::app::{json, take_args}; -use crate::event::Op; -use crate::permission::ExecutionPolicy; -use crate::scene::{self, ApplyStrength, SceneArtifactKind, SceneArtifactSpec, SceneLibrary}; -use crate::scene_artifact::{SceneArtifactRecord, SceneArtifactStore}; -use crate::store::{PipelineInstance, PipelineTransitionRecord}; +use codetwo_core::event::Op; +use codetwo_core::permission::ExecutionPolicy; +use codetwo_core::scene::{ + self, ApplyStrength, SceneArtifactKind, SceneArtifactSpec, SceneLibrary, +}; +use codetwo_core::scene_artifact::{SceneArtifactRecord, SceneArtifactStore}; +use codetwo_core::store::{PipelineInstance, PipelineTransitionRecord}; use codetwo_kernel::{async_trait, Context, Injection, Plugin, PluginError, PluginResult}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -499,7 +501,7 @@ struct SceneApplyOutcome { #[derive(Serialize)] struct SceneSessionPlanOutcome { - params: Option, + params: Option, escalation: Option, } @@ -939,7 +941,7 @@ struct PipelineStartOutcome { struct PipelineAdvanceOutcome { instance: PipelineInstance, applied_scene: Option, - session_plan: Option, + session_plan: Option, escalation: Option, carried: Vec, } diff --git a/crates/core/src/app/plugins/terminal.rs b/crates/plugins/src/app/plugins/terminal.rs similarity index 98% rename from crates/core/src/app/plugins/terminal.rs rename to crates/plugins/src/app/plugins/terminal.rs index dcf4c010..fea120d1 100644 --- a/crates/core/src/app/plugins/terminal.rs +++ b/crates/plugins/src/app/plugins/terminal.rs @@ -4,7 +4,7 @@ use crate::app::service::{TerminalEvent, TerminalService}; use crate::app::TerminalOutputEvent; use crate::app::{json, take_args}; use crate::app::{PluginManager, ProjectActivityLease}; -use crate::term::{Scope, TerminalConfig, TerminalHandle, TerminalOutput}; +use codetwo_core::term::{Scope, TerminalConfig, TerminalHandle, TerminalOutput}; use codetwo_kernel::{ async_trait, CommandRealm, Context, Injection, Plugin, PluginError, PluginResult, WeakContext, }; @@ -61,7 +61,7 @@ impl Plugin for TerminalPlugin { }); ctx.command("terminal.tmux_available", |_| async move { - Ok(Value::Bool(crate::tmux::is_available())) + Ok(Value::Bool(codetwo_core::tmux::is_available())) })?; #[derive(Deserialize)] diff --git a/crates/core/src/app/plugins/utility.rs b/crates/plugins/src/app/plugins/utility.rs similarity index 55% rename from crates/core/src/app/plugins/utility.rs rename to crates/plugins/src/app/plugins/utility.rs index 3050c8e3..6f818f28 100644 --- a/crates/core/src/app/plugins/utility.rs +++ b/crates/plugins/src/app/plugins/utility.rs @@ -9,15 +9,15 @@ pub struct UsagePlugin; #[derive(Serialize)] struct UsageReport { - windows: Vec, + windows: Vec, by_source: Vec<(String, u64)>, transcripts: usize, } #[derive(Serialize)] struct UsageHistoryReport { - history: crate::usage::UsageHistory, - by_source: Vec, + history: codetwo_core::usage::UsageHistory, + by_source: Vec, } #[async_trait] @@ -32,14 +32,14 @@ impl Plugin for UsagePlugin { async fn apply(&self, ctx: Context, _config: Value) -> PluginResult { ctx.command("usage.report", |_| async move { - let scan = tokio::task::spawn_blocking(crate::usage::scan_all_with_count) + let scan = tokio::task::spawn_blocking(codetwo_core::usage::scan_all_with_count) .await .unwrap_or_default(); - let now = crate::session::now_millis(); - let limits = crate::usage::Limits::from_env(); + let now = codetwo_core::session::now_millis(); + let limits = codetwo_core::usage::Limits::from_env(); json(UsageReport { - windows: crate::usage::windows(&scan.records, now, &limits), - by_source: crate::usage::by_source(&scan.records), + windows: codetwo_core::usage::windows(&scan.records, now, &limits), + by_source: codetwo_core::usage::by_source(&scan.records), transcripts: scan.transcripts, }) })?; @@ -50,10 +50,10 @@ impl Plugin for UsagePlugin { } ctx.command("usage.history", |args| async move { let args: HistoryArgs = take_args(args)?; - let scan = tokio::task::spawn_blocking(crate::usage::scan_all_with_count) + let scan = tokio::task::spawn_blocking(codetwo_core::usage::scan_all_with_count) .await .unwrap_or_default(); - let now = crate::session::now_millis(); + let now = codetwo_core::session::now_millis(); let (bucket_secs, bucket_count) = if args.days <= 7 { (3_600i64, 7 * 24) } else { @@ -61,8 +61,13 @@ impl Plugin for UsagePlugin { }; let cutoff = now - bucket_secs * 1000 * bucket_count as i64; json(UsageHistoryReport { - history: crate::usage::history(&scan.records, now, bucket_secs, bucket_count), - by_source: crate::usage::by_source_detailed(&scan.records, cutoff), + history: codetwo_core::usage::history( + &scan.records, + now, + bucket_secs, + bucket_count, + ), + by_source: codetwo_core::usage::by_source_detailed(&scan.records, cutoff), }) })?; @@ -73,20 +78,20 @@ impl Plugin for UsagePlugin { ctx.command("usage.provider_quota", |args| async move { let args: ProviderQuotaArgs = take_args(args)?; let provider = match args.provider.as_str() { - "claude_code" => crate::provider::ProviderId::ClaudeCode, - "codex" => crate::provider::ProviderId::Codex, - "grok" => crate::provider::ProviderId::Grok, - "cursor" => crate::provider::ProviderId::Cursor, - "opencode" => crate::provider::ProviderId::OpenCode, - "opencode2" => crate::provider::ProviderId::OpenCode2, - "pi" => crate::provider::ProviderId::Pi, - "kimi" => crate::provider::ProviderId::Kimi, - "zcode" => crate::provider::ProviderId::ZCode, - "amp" => crate::provider::ProviderId::Amp, - "droid" => crate::provider::ProviderId::Droid, - other => crate::provider::ProviderId::Custom(other.to_string()), + "claude_code" => codetwo_core::provider::ProviderId::ClaudeCode, + "codex" => codetwo_core::provider::ProviderId::Codex, + "grok" => codetwo_core::provider::ProviderId::Grok, + "cursor" => codetwo_core::provider::ProviderId::Cursor, + "opencode" => codetwo_core::provider::ProviderId::OpenCode, + "opencode2" => codetwo_core::provider::ProviderId::OpenCode2, + "pi" => codetwo_core::provider::ProviderId::Pi, + "kimi" => codetwo_core::provider::ProviderId::Kimi, + "zcode" => codetwo_core::provider::ProviderId::ZCode, + "amp" => codetwo_core::provider::ProviderId::Amp, + "droid" => codetwo_core::provider::ProviderId::Droid, + other => codetwo_core::provider::ProviderId::Custom(other.to_string()), }; - json(crate::usage::provider_quota(&provider).await) + json(codetwo_core::usage::provider_quota(&provider).await) })?; Ok(()) } @@ -106,7 +111,7 @@ impl Plugin for VoicePlugin { async fn apply(&self, ctx: Context, _config: Value) -> PluginResult { ctx.command("voice.available", |_| async move { - Ok(Value::Bool(crate::voice::is_available())) + Ok(Value::Bool(codetwo_core::voice::is_available())) })?; #[derive(Deserialize)] @@ -117,9 +122,10 @@ impl Plugin for VoicePlugin { } ctx.command("voice.transcribe", |args| async move { let args: TranscribeArgs = take_args(args)?; - let path = crate::voice::save_audio(&args.bytes, args.ext.as_deref().unwrap_or("webm")) - .map_err(PluginError::new)?; - let result = crate::voice::transcribe(&path) + let path = + codetwo_core::voice::save_audio(&args.bytes, args.ext.as_deref().unwrap_or("webm")) + .map_err(PluginError::new)?; + let result = codetwo_core::voice::transcribe(&path) .await .map_err(PluginError::new); let _ = std::fs::remove_file(&path); diff --git a/crates/core/src/app/plugins/workspace.rs b/crates/plugins/src/app/plugins/workspace.rs similarity index 92% rename from crates/core/src/app/plugins/workspace.rs rename to crates/plugins/src/app/plugins/workspace.rs index c778594b..de2c9498 100644 --- a/crates/core/src/app/plugins/workspace.rs +++ b/crates/plugins/src/app/plugins/workspace.rs @@ -1,15 +1,15 @@ //! Workspace-facing plugins: git, key bindings, the skill market. //! //! `git` is the clearest example of what the command registry buys us. It injects nothing, owns no -//! state, and every one of its thirteen commands is a two-line call into [`crate::git`] — yet +//! state, and every one of its thirteen commands is a two-line call into [`codetwo_core::git`] — yet //! before this each one existed twice, as a core function and as a hand-written desktop wrapper //! wrapper listed in a 185-entry table. Here the plugin *is* the registration. use crate::app::events::SkillsChanged; use crate::app::service::{KeymapService, Paths, SkillService}; use crate::app::{json, take_args}; -use crate::git; -use crate::keymap::Action; +use codetwo_core::git; +use codetwo_core::keymap::Action; use codetwo_kernel::{async_trait, Context, Injection, Plugin, PluginError, PluginResult}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -245,16 +245,16 @@ impl Plugin for MarketPlugin { async move { let library = skills.library(); json( - crate::market::builtin_catalog() + codetwo_core::market::builtin_catalog() .into_iter() .map(|entry| MarketItem { installed: library.get(&entry.id).is_some(), kind: match entry.kind() { - crate::skill::SkillKind::Fragment => "fragment", - crate::skill::SkillKind::AgentSkill => "agent_skill", - crate::skill::SkillKind::Subagent => "subagent", - crate::skill::SkillKind::Mcp => "mcp", - crate::skill::SkillKind::Macro => "macro", + codetwo_core::skill::SkillKind::Fragment => "fragment", + codetwo_core::skill::SkillKind::AgentSkill => "agent_skill", + codetwo_core::skill::SkillKind::Subagent => "subagent", + codetwo_core::skill::SkillKind::Mcp => "mcp", + codetwo_core::skill::SkillKind::Macro => "macro", }, id: entry.id, name: entry.name, @@ -279,7 +279,7 @@ impl Plugin for MarketPlugin { let weak = weak.clone(); async move { let args: InstallArgs = take_args(args)?; - let entry = crate::market::builtin_catalog() + let entry = codetwo_core::market::builtin_catalog() .into_iter() .find(|entry| entry.id == args.id) .ok_or_else(|| { @@ -299,7 +299,7 @@ impl Plugin for MarketPlugin { } ctx.command("market.parse", |args| async move { let args: ParseArgs = take_args(args)?; - json(crate::market::parse_catalog(&args.json).map_err(PluginError::new)?) + json(codetwo_core::market::parse_catalog(&args.json).map_err(PluginError::new)?) })?; Ok(()) } diff --git a/crates/core/src/app/plugins/workspace_io.rs b/crates/plugins/src/app/plugins/workspace_io.rs similarity index 95% rename from crates/core/src/app/plugins/workspace_io.rs rename to crates/plugins/src/app/plugins/workspace_io.rs index f36d2fd4..7db7ee7d 100644 --- a/crates/core/src/app/plugins/workspace_io.rs +++ b/crates/plugins/src/app/plugins/workspace_io.rs @@ -5,12 +5,12 @@ use crate::app::service::{Paths, StoreService}; use crate::app::{json, take_args}; -use crate::artifact::ArtifactStore; -use crate::project::{self, ProjectWorktreeMode}; -use crate::workspace; -use crate::workspace_search::{self, WorkspaceSearchCancellation, WorkspaceSearchOptions}; -use crate::worktree::{ResolvedWorktreeBaseline, WorktreeBaseline}; use base64::Engine as _; +use codetwo_core::artifact::ArtifactStore; +use codetwo_core::project::{self, ProjectWorktreeMode}; +use codetwo_core::workspace; +use codetwo_core::workspace_search::{self, WorkspaceSearchCancellation, WorkspaceSearchOptions}; +use codetwo_core::worktree::{ResolvedWorktreeBaseline, WorktreeBaseline}; use codetwo_kernel::{ async_trait, CommandRealm, Context, Injection, Plugin, PluginError, PluginResult, }; @@ -113,12 +113,12 @@ impl Plugin for WorkspacePlugin { let data_dir = attachment_data_dir.clone(); async move { let args: ImportAttachmentArgs = take_args(args)?; - let attachment = crate::attachment::import_prompt_attachment( + let attachment = codetwo_core::attachment::import_prompt_attachment( &data_dir, &args.name, args.declared_mime.as_deref(), &args.bytes, - crate::session::now_millis(), + codetwo_core::session::now_millis(), ) .map_err(PluginError::new)?; json(serde_json::json!({ @@ -150,8 +150,9 @@ impl Plugin for WorkspacePlugin { let data_dir = attachment_data_dir.clone(); async move { let args: GetAttachmentArgs = take_args(args)?; - let attachment = crate::attachment::load_prompt_attachment(&data_dir, &args.id) - .map_err(PluginError::new)?; + let attachment = + codetwo_core::attachment::load_prompt_attachment(&data_dir, &args.id) + .map_err(PluginError::new)?; json(serde_json::json!({ "id": attachment.id, "kind": "attachment", @@ -197,7 +198,7 @@ impl Plugin for WorkspacePlugin { ctx.command("workspace.rules", |args| async move { let args: CwdArgs = take_args(args)?; json( - crate::rules::load(Path::new(&args.cwd)) + codetwo_core::rules::load(Path::new(&args.cwd)) .into_iter() .map(|rule| rule.path) .collect::>(), @@ -207,7 +208,7 @@ impl Plugin for WorkspacePlugin { ctx.command("workspace.source_control", |args| async move { let args: CwdArgs = take_args(args)?; json( - crate::source_control::inspect(Path::new(&args.cwd)) + codetwo_core::source_control::inspect(Path::new(&args.cwd)) .await .map_err(PluginError::new)?, ) @@ -224,7 +225,7 @@ impl Plugin for WorkspacePlugin { id: String, name: String, #[serde(default)] - kind: crate::project::ProjectActionKind, + kind: codetwo_core::project::ProjectActionKind, #[serde(default)] command: String, #[serde(default)] @@ -242,7 +243,7 @@ impl Plugin for WorkspacePlugin { let args: SaveScriptArgs = take_args(args)?; json(project::save_script( Path::new(&args.cwd), - &crate::project::ProjectScript { + &codetwo_core::project::ProjectScript { id: args.id, name: args.name, kind: args.kind, @@ -303,8 +304,8 @@ struct WorktreeBaselineOption { async fn resolve_baselines(cwd: &Path) -> Vec { let (current, origin_default) = tokio::join!( - crate::worktree::resolve_baseline(cwd, WorktreeBaseline::Current), - crate::worktree::resolve_baseline(cwd, WorktreeBaseline::OriginDefault), + codetwo_core::worktree::resolve_baseline(cwd, WorktreeBaseline::Current), + codetwo_core::worktree::resolve_baseline(cwd, WorktreeBaseline::OriginDefault), ); [ (WorktreeBaseline::Current, current), @@ -373,7 +374,11 @@ impl Plugin for ProjectsPlugin { } let path = resolved.to_string_lossy().into_owned(); store - .add_project(&path, args.name.as_deref(), crate::session::now_millis()) + .add_project( + &path, + args.name.as_deref(), + codetwo_core::session::now_millis(), + ) .map_err(PluginError::new)?; json(path) } @@ -389,7 +394,7 @@ impl Plugin for ProjectsPlugin { async move { let args: PathOnly = take_args(args)?; store - .touch_project(&args.path, crate::session::now_millis()) + .touch_project(&args.path, codetwo_core::session::now_millis()) .map_err(PluginError::new)?; Ok(Value::Bool(true)) } @@ -478,7 +483,7 @@ impl Plugin for ProjectsPlugin { .map_err(PluginError::new)?; let Some(source) = args.source else { let revision = store - .set_project_icon(&args.path, None, crate::session::now_millis()) + .set_project_icon(&args.path, None, codetwo_core::session::now_millis()) .map_err(PluginError::new)?; remove_stored_project_icon(&icon_dir, previous.as_deref()); return json(revision); @@ -497,7 +502,7 @@ impl Plugin for ProjectsPlugin { let revision = match store.set_project_icon( &args.path, Some(&destination_text), - crate::session::now_millis(), + codetwo_core::session::now_millis(), ) { Ok(revision) => revision, Err(error) => { @@ -727,7 +732,7 @@ fn read_visualization(path: &str, realm: &CommandRealm) -> Result Result Option { - crate::provider::home_dir() + codetwo_core::provider::home_dir() } fn reveal(path: &Path) -> Result<(), String> { diff --git a/crates/core/src/app/protocol/mod.rs b/crates/plugins/src/app/protocol/mod.rs similarity index 98% rename from crates/core/src/app/protocol/mod.rs rename to crates/plugins/src/app/protocol/mod.rs index ab3a54a6..4d9310d0 100644 --- a/crates/core/src/app/protocol/mod.rs +++ b/crates/plugins/src/app/protocol/mod.rs @@ -44,7 +44,7 @@ pub use wire::{ InvokeParams, LogParams, PROTOCOL_VERSION, }; -use crate::plugin::{PluginRuntimeCommand, PluginRuntimeSpec}; +use crate::bundle::{PluginRuntimeCommand, PluginRuntimeSpec}; use codetwo_kernel::{ async_trait, CommandRealm, Context, Injection, Plugin, PluginError, PluginResult, WeakContext, }; @@ -65,7 +65,7 @@ pub struct Channel { } /// How a plugin is started. A trait rather than a concrete process spawn so the protocol is -/// testable over an in-memory duplex — the same trick [`crate::acp`] uses to test the prompt turn +/// testable over an in-memory duplex — the same trick [`codetwo_core::acp`] uses to test the prompt turn /// without a provider binary. #[async_trait] pub trait Transport: Send + Sync + 'static { @@ -129,8 +129,8 @@ fn is_executable_bundle_command(path: &Path) -> bool { #[async_trait] impl Transport for ProcessTransport { async fn start(&self) -> Result { - let executable = - crate::provider::which(&self.command).unwrap_or_else(|| self.command.clone().into()); + let executable = codetwo_core::provider::which(&self.command) + .unwrap_or_else(|| self.command.clone().into()); let mut command = tokio::process::Command::new(executable); command .args(&self.args) diff --git a/crates/core/src/app/protocol/peer.rs b/crates/plugins/src/app/protocol/peer.rs similarity index 98% rename from crates/core/src/app/protocol/peer.rs rename to crates/plugins/src/app/protocol/peer.rs index 02e9e67e..151fbdd8 100644 --- a/crates/core/src/app/protocol/peer.rs +++ b/crates/plugins/src/app/protocol/peer.rs @@ -1,6 +1,6 @@ //! A JSON-RPC 2.0 peer for the plugin protocol. //! -//! Deliberately generic over the byte streams, like [`crate::acp::connection`]: in production the +//! Deliberately generic over the byte streams, like [`codetwo_core::acp::connection`]: in production the //! reader/writer are a child process's stdout/stdin, and in tests they are an in-memory duplex, so //! the whole handshake-and-dispatch loop is exercised offline with no plugin binary to install. diff --git a/crates/core/src/app/protocol/wire.rs b/crates/plugins/src/app/protocol/wire.rs similarity index 100% rename from crates/core/src/app/protocol/wire.rs rename to crates/plugins/src/app/protocol/wire.rs diff --git a/crates/core/src/app/service.rs b/crates/plugins/src/app/service.rs similarity index 90% rename from crates/core/src/app/service.rs rename to crates/plugins/src/app/service.rs index 45a429f1..9bc1c3a1 100644 --- a/crates/core/src/app/service.rs +++ b/crates/plugins/src/app/service.rs @@ -6,22 +6,23 @@ //! reached by anything that declares it in `inject`. The wiring is no longer a place in the code. use crate::app::{PluginConfigStore, PluginScope}; -use crate::canvas::CanvasFeatureGate; -use crate::engine::Engine; -use crate::event::Event; -use crate::host_tools::HostToolDiscovery; -use crate::keymap::Keymap; -use crate::models::available_models; -use crate::provider::{ +use codetwo_core::canvas::CanvasFeatureGate; +use codetwo_core::engine::Engine; +use codetwo_core::event::Event; +use codetwo_core::host_tools::HostToolDiscovery; +use codetwo_core::keymap::Keymap; +use codetwo_core::memory::MemoryCapability; +use codetwo_core::models::available_models; +use codetwo_core::provider::{ Provider, ProviderCapability, ProviderToolset, CODEX_ACP_PACKAGE, CODEX_ACP_VERSION, }; -use crate::provider_lifecycle::{ +use codetwo_core::provider_lifecycle::{ ProviderLaunchMode, ProviderLifecycleManager, ProviderLifecycleStatus, }; -use crate::scene::SceneLibrary; -use crate::scene_artifact::SceneArtifactStore; -use crate::skill::{builtin_skills, Skill, SkillLibrary}; -use crate::store::Store; +use codetwo_core::scene::SceneLibrary; +use codetwo_core::scene_artifact::SceneArtifactStore; +use codetwo_core::skill::{builtin_skills, Skill, SkillLibrary}; +use codetwo_core::store::Store; use codetwo_kernel::Service; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -93,6 +94,14 @@ impl Service for StoreService { const NAME: &'static str = "store"; } +/// Revocable product memory published through the plugin kernel without coupling Core to it. +#[derive(Clone)] +pub struct MemoryService(pub MemoryCapability); + +impl Service for MemoryService { + const NAME: &'static str = "memory"; +} + /// Canvas persistence policy and owner identity. The gate remains closed in production builds; /// publishing it as a service keeps every canvas command behind the same check. pub struct CanvasService { @@ -129,7 +138,7 @@ pub enum TerminalEvent { /// Live terminal emulators and their host-facing event stream. pub struct TerminalService { - pub(crate) terminals: Mutex>, + pub(crate) terminals: Mutex>, events: broadcast::Sender, } @@ -178,7 +187,7 @@ pub struct ProviderSummary { pub available: bool, pub enabled: bool, pub needs_node: bool, - pub models: Vec, + pub models: Vec, pub capabilities: Vec, pub management: ProviderLifecycleStatus, } @@ -255,11 +264,11 @@ impl ProviderService { self.provider_tools.clone() } - pub fn computer_use_settings(&self) -> crate::host_tools::ComputerUseSettings { + pub fn computer_use_settings(&self) -> codetwo_core::host_tools::ComputerUseSettings { self.host_tools.read().unwrap().computer_use_settings() } - pub fn browser_use_settings(&self) -> crate::host_tools::BrowserUseSettings { + pub fn browser_use_settings(&self) -> codetwo_core::host_tools::BrowserUseSettings { self.host_tools.read().unwrap().browser_use_settings() } @@ -413,7 +422,7 @@ impl SkillService { skills.push(skill); } } - if let Ok(plugins) = crate::plugin::load_dir(&self.paths.plugins()) { + if let Ok(plugins) = crate::bundle::load_dir(&self.paths.plugins()) { for plugin in plugins { for skill in plugin.components { source_enabled.insert(skill.id.clone(), plugin.enabled); @@ -421,7 +430,7 @@ impl SkillService { } } } - for skill in crate::harness::discover(cwd.as_deref()) { + for skill in codetwo_core::harness::discover(cwd.as_deref()) { source_enabled.insert(skill.id.clone(), true); skills.push(skill); } @@ -512,7 +521,8 @@ impl SceneService { } let cwd = self.cwd.lock().unwrap().clone(); let project_dir = cwd.map(|cwd| cwd.join(".codetwo/scenes")); - let user_dir = crate::provider::home_dir().map(|home| home.join(".config/codetwo/scenes")); + let user_dir = + codetwo_core::provider::home_dir().map(|home| home.join(".config/codetwo/scenes")); let plugins = hub.map(PluginHub::scene_dirs).unwrap_or_default(); self.set_library(Arc::new(SceneLibrary::load( project_dir.as_deref(), @@ -523,28 +533,28 @@ impl SceneService { } /// The Agent Scenes hook dispatcher. -pub struct SceneRuntimeService(pub Arc); +pub struct SceneRuntimeService(pub Arc); impl Service for SceneRuntimeService { const NAME: &'static str = "scene-runtime"; } impl std::ops::Deref for SceneRuntimeService { - type Target = crate::scene_runtime::SceneRuntime; + type Target = codetwo_core::scene_runtime::SceneRuntime; fn deref(&self) -> &Self::Target { &self.0 } } /// Per-session token and cost accounting. -pub struct CostService(pub Arc); +pub struct CostService(pub Arc); impl Service for CostService { const NAME: &'static str = "cost"; } impl std::ops::Deref for CostService { - type Target = crate::cost::SessionCostTracker; + type Target = codetwo_core::cost::SessionCostTracker; fn deref(&self) -> &Self::Target { &self.0 } @@ -565,14 +575,14 @@ impl std::ops::Deref for EngineService { } /// Durable source/target fencing plus portable workspace transfer. -pub struct HandoffService(pub Arc); +pub struct HandoffService(pub Arc); impl Service for HandoffService { const NAME: &'static str = "handoff"; } impl std::ops::Deref for HandoffService { - type Target = crate::handoff::TaskHandoffManager; + type Target = codetwo_core::handoff::TaskHandoffManager; fn deref(&self) -> &Self::Target { &self.0 } @@ -601,7 +611,11 @@ impl KeymapService { self.keymap.lock().unwrap().clone() } - pub fn set(&self, action: crate::keymap::Action, key: String) -> std::io::Result { + pub fn set( + &self, + action: codetwo_core::keymap::Action, + key: String, + ) -> std::io::Result { let mut keymap = self.keymap.lock().unwrap(); keymap.set(action, key); keymap.save(&self.path)?; @@ -613,7 +627,7 @@ impl KeymapService { /// scaffolds) users install from GitHub. /// /// A [`codetwo_kernel::Plugin`] is an internal runtime module; a -/// [`crate::plugin::InstalledPlugin`] is a separately managed extension Bundle. This Core service +/// [`crate::bundle::InstalledPlugin`] is a separately managed extension Bundle. This Core service /// bridges the two without making the kernel trait a public extension API. pub struct PluginHub { pub dir: PathBuf, @@ -626,8 +640,8 @@ impl Service for PluginHub { } impl PluginHub { - pub fn installed(&self) -> Vec { - crate::plugin::load_dir(&self.dir).unwrap_or_default() + pub fn installed(&self) -> Vec { + crate::bundle::load_dir(&self.dir).unwrap_or_default() } /// `(id, scenes dir)` for every enabled plugin that ships scenes — what the scene loader reads. @@ -636,7 +650,7 @@ impl PluginHub { .into_iter() .filter(|plugin| plugin.enabled) .map(|plugin| { - let dir = crate::plugin::plugin_scenes_dir(&self.dir, &plugin.id); + let dir = crate::bundle::plugin_scenes_dir(&self.dir, &plugin.id); (plugin.id, dir) }) .filter(|(_, dir)| dir.is_dir()) diff --git a/crates/core/src/plugin.rs b/crates/plugins/src/bundle.rs similarity index 99% rename from crates/core/src/plugin.rs rename to crates/plugins/src/bundle.rs index 32568e43..ead3e8a8 100644 --- a/crates/core/src/plugin.rs +++ b/crates/plugins/src/bundle.rs @@ -12,9 +12,11 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use thiserror::Error; -use crate::github_skills::GitHubCheckout; -use crate::harness::parse_frontmatter; -use crate::skill::{McpServer, McpTransport, Skill, SkillKind, SkillPayload, SubagentDefinition}; +use codetwo_core::github_skills::GitHubCheckout; +use codetwo_core::harness::parse_frontmatter; +use codetwo_core::skill::{ + McpServer, McpTransport, Skill, SkillKind, SkillPayload, SubagentDefinition, +}; const RECORD_FILE: &str = "installed-plugin.json"; const BUNDLE_DIR: &str = "bundle"; @@ -632,7 +634,7 @@ pub fn from_local( } let checkout = GitHubCheckout { root: temporary, - spec: crate::github_skills::GitHubRepoSpec { + spec: codetwo_core::github_skills::GitHubRepoSpec { owner: "local".into(), repo: format!("source-{:08x}", fnv1a(source_identity.as_bytes())), reference: None, @@ -2059,9 +2061,10 @@ fn count_scene_components(root: &Path) -> (usize, usize) { let parsed = std::fs::read_to_string(&path) .map_err(|e| e.to_string()) .and_then(|data| { - serde_json::from_str::(&data).map_err(|e| e.to_string()) + serde_json::from_str::(&data) + .map_err(|e| e.to_string()) }) - .and_then(|scene| crate::scene::validate_scene(&scene).map(|()| scene)); + .and_then(|scene| codetwo_core::scene::validate_scene(&scene).map(|()| scene)); match parsed { Ok(_) => scenes += 1, Err(error) => tracing::warn!("plugin scene {path:?}: {error} (skipped)"), @@ -2070,9 +2073,12 @@ fn count_scene_components(root: &Path) -> (usize, usize) { let parsed = std::fs::read_to_string(&path) .map_err(|e| e.to_string()) .and_then(|data| { - serde_json::from_str::(&data).map_err(|e| e.to_string()) + serde_json::from_str::(&data) + .map_err(|e| e.to_string()) }) - .and_then(|pipeline| crate::scene::validate_pipeline(&pipeline).map(|()| pipeline)); + .and_then(|pipeline| { + codetwo_core::scene::validate_pipeline(&pipeline).map(|()| pipeline) + }); match parsed { Ok(_) => pipelines += 1, Err(error) => tracing::warn!("plugin pipeline {path:?}: {error} (skipped)"), @@ -2757,7 +2763,7 @@ mod tests { // GitHubCheckout owns and removes its root on drop, so it must only receive this temp copy. let bundle = from_github(&GitHubCheckout { root, - spec: crate::github_skills::GitHubRepoSpec { + spec: codetwo_core::github_skills::GitHubRepoSpec { owner: "IchenDEV".into(), repo: "codeTwo".into(), reference: None, @@ -2798,7 +2804,7 @@ mod tests { fn checkout(root: PathBuf) -> GitHubCheckout { GitHubCheckout { root, - spec: crate::github_skills::GitHubRepoSpec { + spec: codetwo_core::github_skills::GitHubRepoSpec { owner: "acme".into(), repo: "developer-kit".into(), reference: None, @@ -3261,7 +3267,7 @@ mod tests { &root.join("scenes/valid.scene.json"), &format!( r#"{{"$schema":"{}","name":"valid","title":"Valid scene"}}"#, - crate::scene::SCENE_SCHEMA_ID + codetwo_core::scene::SCENE_SCHEMA_ID ), ); // Malformed JSON: must be warned about and skipped, never failing the install. @@ -3270,7 +3276,7 @@ mod tests { &root.join("scenes/flow.pipeline.json"), &format!( r#"{{"$schema":"{}","name":"flow","title":"Flow","stages":[{{"id":"a","scene":"valid"}}]}}"#, - crate::scene::PIPELINE_SCHEMA_ID + codetwo_core::scene::PIPELINE_SCHEMA_ID ), ); diff --git a/crates/plugins/src/lib.rs b/crates/plugins/src/lib.rs new file mode 100644 index 00000000..9e6280a9 --- /dev/null +++ b/crates/plugins/src/lib.rs @@ -0,0 +1,36 @@ +//! C2's plugin composition layer. +//! +//! This is C2's shared composition root between the product [`codetwo_core`] and the generic +//! [`codetwo_kernel`]. It turns Core capabilities into built-in runtime modules, manages installed +//! extension Bundles, and exposes the single host-facing [`CoreApp`] seam. Host binaries may add +//! their own platform modules, but shared plugin behavior belongs here. +//! +//! Dependency direction is intentionally one way: +//! +//! ```text +//! codetwo-plugins -> codetwo-core +//! -> codetwo-kernel +//! ``` +//! +//! Core contains no plugin lifecycle, bundle, or protocol knowledge. + +mod app; + +pub mod bundle; +pub mod marketplace; + +pub use app::events; +pub use app::plugins as builtins; +pub use app::protocol; +#[doc(hidden)] +pub use app::testing; +pub use app::{ + normalize_project_path, AppConfig, CanvasService, CoreApp, CostService, EngineService, + EventBus, HandoffService, KeymapService, LoaderService, MemoryService, Paths, + PluginActiveResource, PluginCatalog, PluginCatalogEntry, PluginChangePlan, PluginChangeRequest, + PluginChangeResult, PluginConfigDocument, PluginConfigError, PluginConfigService, + PluginConfigStore, PluginHub, PluginManager, PluginManagerError, PluginOverride, PluginPolicy, + PluginRecoveryState, PluginScope, ProjectActivityLease, ProviderService, ProviderSummary, + SceneRuntimeService, SceneService, SkillService, StoreService, TerminalEvent, + TerminalOutputEvent, TerminalService, +}; diff --git a/crates/core/src/plugin_marketplace.rs b/crates/plugins/src/marketplace.rs similarity index 100% rename from crates/core/src/plugin_marketplace.rs rename to crates/plugins/src/marketplace.rs diff --git a/crates/core/tests/app_graph.rs b/crates/plugins/tests/app_graph.rs similarity index 98% rename from crates/core/tests/app_graph.rs rename to crates/plugins/tests/app_graph.rs index 3656e24a..78ee3cf7 100644 --- a/crates/core/tests/app_graph.rs +++ b/crates/plugins/tests/app_graph.rs @@ -2,11 +2,12 @@ //! while it runs. use base64::Engine as _; -use codetwo_core::app::plugins::{EngineInputs, EnginePlugin}; -use codetwo_core::app::{AppConfig, CoreApp, EngineService, StoreService}; use codetwo_core::session::{RunFailureReason, SessionActivity, SessionRunState}; use codetwo_core::{Engine, ProviderId, Session}; use codetwo_kernel::{CommandVisibility, KernelError, PluginEntry, Status}; +use codetwo_plugins::builtins::{EngineInputs, EnginePlugin}; +use codetwo_plugins::testing::CoreAppTestExt; +use codetwo_plugins::{AppConfig, CoreApp, EngineService, StoreService}; use serde_json::{json, Value}; use std::sync::Arc; @@ -57,7 +58,7 @@ async fn change_plugin( async fn the_default_config_boots_every_builtin() { let (app, _dir) = boot().await; - for plugin in codetwo_core::app::plugins::BUILTIN { + for plugin in codetwo_plugins::builtins::BUILTIN { assert_eq!( status_of(&app, plugin), Status::Active, @@ -432,7 +433,7 @@ async fn disabling_a_leaf_removes_its_service_and_command_surface() { #[tokio::test] async fn disabling_a_required_host_capability_rolls_back_the_transaction() { let dir = tempfile::tempdir().unwrap(); - let mut registry = codetwo_core::app::plugins::builtin_registry(); + let mut registry = codetwo_plugins::builtins::builtin_registry(); registry.register_arc(Box::new(|| { Arc::new(EnginePlugin::with_builder_and_required( Arc::new(|inputs: EngineInputs| { diff --git a/crates/core/tests/memory_plugin_lifecycle.rs b/crates/plugins/tests/memory_plugin_lifecycle.rs similarity index 89% rename from crates/core/tests/memory_plugin_lifecycle.rs rename to crates/plugins/tests/memory_plugin_lifecycle.rs index eb65377b..ca1f8821 100644 --- a/crates/core/tests/memory_plugin_lifecycle.rs +++ b/crates/plugins/tests/memory_plugin_lifecycle.rs @@ -1,7 +1,6 @@ //! Memory is an optional, revocable engine capability rather than part of persistence itself. -use codetwo_core::app::{AppConfig, CoreApp}; -use codetwo_core::memory::MemoryCapability; +use codetwo_plugins::{AppConfig, CoreApp, MemoryService}; use serde_json::{json, Value}; #[tokio::test] @@ -15,7 +14,7 @@ async fn disabling_memory_keeps_the_engine_and_store_online() { ) }); let app = CoreApp::boot(config).await.unwrap(); - assert!(app.service::().is_some()); + assert!(app.service::().is_some()); assert!(app.call("memory.settings", Value::Null).await.is_ok()); assert!(app .commands() @@ -40,7 +39,7 @@ async fn disabling_memory_keeps_the_engine_and_store_online() { .await .unwrap(); - assert!(app.service::().is_none()); + assert!(app.service::().is_none()); assert!(app.call("memory.settings", Value::Null).await.is_err()); assert!(app .commands() diff --git a/crates/core/tests/plugin_config.rs b/crates/plugins/tests/plugin_config.rs similarity index 99% rename from crates/core/tests/plugin_config.rs rename to crates/plugins/tests/plugin_config.rs index c3e1bbc8..fc8369e6 100644 --- a/crates/core/tests/plugin_config.rs +++ b/crates/plugins/tests/plugin_config.rs @@ -1,4 +1,4 @@ -use codetwo_core::app::{ +use codetwo_plugins::{ PluginConfigStore, PluginOverride, PluginPolicy, PluginRecoveryState, PluginScope, }; diff --git a/crates/core/tests/plugin_manager.rs b/crates/plugins/tests/plugin_manager.rs similarity index 99% rename from crates/core/tests/plugin_manager.rs rename to crates/plugins/tests/plugin_manager.rs index 9407d3aa..f997e86d 100644 --- a/crates/core/tests/plugin_manager.rs +++ b/crates/plugins/tests/plugin_manager.rs @@ -1,13 +1,14 @@ -use codetwo_core::app::events::PluginsChanged; -use codetwo_core::app::plugins::KernelPlugin; -use codetwo_core::app::{ - AppConfig, CoreApp, PluginChangeRequest, PluginConfigStore, PluginManagerError, PluginOverride, - PluginPolicy, PluginRecoveryState, PluginScope, -}; use codetwo_kernel::{ async_trait, Context, Injection, Plugin, PluginCategory, PluginEntry, PluginMetadata, PluginRegistry, PluginResult, PluginScopeSupport, Service, Status, }; +use codetwo_plugins::builtins::KernelPlugin; +use codetwo_plugins::events::PluginsChanged; +use codetwo_plugins::testing::CoreAppTestExt; +use codetwo_plugins::{ + AppConfig, CoreApp, PluginChangeRequest, PluginConfigStore, PluginManagerError, PluginOverride, + PluginPolicy, PluginRecoveryState, PluginScope, +}; use serde_json::{json, Value}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Arc; diff --git a/crates/core/tests/plugin_metadata.rs b/crates/plugins/tests/plugin_metadata.rs similarity index 97% rename from crates/core/tests/plugin_metadata.rs rename to crates/plugins/tests/plugin_metadata.rs index 4afe08f7..cd2650a3 100644 --- a/crates/core/tests/plugin_metadata.rs +++ b/crates/plugins/tests/plugin_metadata.rs @@ -1,7 +1,7 @@ use std::collections::BTreeSet; -use codetwo_core::app::plugins::{builtin_registry, BUILTIN, CORE}; use codetwo_kernel::{PluginCategory, PluginOrigin, PluginRole, PluginScopeSupport}; +use codetwo_plugins::builtins::{builtin_registry, BUILTIN, CORE}; #[test] fn builtins_have_complete_catalog_metadata() { diff --git a/crates/core/tests/plugin_process_lifecycle.rs b/crates/plugins/tests/plugin_process_lifecycle.rs similarity index 97% rename from crates/core/tests/plugin_process_lifecycle.rs rename to crates/plugins/tests/plugin_process_lifecycle.rs index 35700f2b..ab9fa534 100644 --- a/crates/core/tests/plugin_process_lifecycle.rs +++ b/crates/plugins/tests/plugin_process_lifecycle.rs @@ -2,9 +2,9 @@ #![cfg(unix)] -use codetwo_core::app::protocol::{ProcessTransport, ProtocolPlugin}; -use codetwo_core::plugin::PluginRuntimeCommand; use codetwo_kernel::{App, Status}; +use codetwo_plugins::bundle::PluginRuntimeCommand; +use codetwo_plugins::protocol::{ProcessTransport, ProtocolPlugin}; use serde_json::Value; use std::path::Path; use std::sync::Arc; diff --git a/crates/core/tests/plugin_protocol.rs b/crates/plugins/tests/plugin_protocol.rs similarity index 99% rename from crates/core/tests/plugin_protocol.rs rename to crates/plugins/tests/plugin_protocol.rs index e4dd5561..1c34faab 100644 --- a/crates/core/tests/plugin_protocol.rs +++ b/crates/plugins/tests/plugin_protocol.rs @@ -4,10 +4,11 @@ //! handshake, the command forwarding, the callbacks and the teardown are all exercised offline — //! the same trick `acp_prompt_turn.rs` uses to test a prompt turn without a provider CLI. -use codetwo_core::app::protocol::{Channel, ProtocolPlugin, Transport, PROTOCOL_VERSION}; -use codetwo_core::app::{AppConfig, CoreApp}; -use codetwo_core::plugin::PluginRuntimeCommand; use codetwo_kernel::{async_trait, CommandRealm, FnPlugin, PluginError, Status}; +use codetwo_plugins::bundle::PluginRuntimeCommand; +use codetwo_plugins::protocol::{Channel, ProtocolPlugin, Transport, PROTOCOL_VERSION}; +use codetwo_plugins::testing::CoreAppTestExt; +use codetwo_plugins::{AppConfig, CoreApp}; use serde_json::{json, Value}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; @@ -842,7 +843,7 @@ async fn an_untrusted_bundle_that_ships_a_process_is_not_started() { // Exactly what installing one does: announce it, and let the plugin rebuild itself. app.ctx() - .emit(codetwo_core::app::events::PluginsChanged) + .emit(codetwo_plugins::events::PluginsChanged) .await; app.flush().await; diff --git a/crates/core/tests/plugin_registry_unknown.rs b/crates/plugins/tests/plugin_registry_unknown.rs similarity index 96% rename from crates/core/tests/plugin_registry_unknown.rs rename to crates/plugins/tests/plugin_registry_unknown.rs index 4ce04f4a..8a335619 100644 --- a/crates/core/tests/plugin_registry_unknown.rs +++ b/crates/plugins/tests/plugin_registry_unknown.rs @@ -1,5 +1,5 @@ -use codetwo_core::app::{AppConfig, CoreApp}; use codetwo_kernel::{PluginEntry, Status}; +use codetwo_plugins::{AppConfig, CoreApp}; use serde_json::{json, Value}; #[tokio::test] diff --git a/crates/core/tests/project_bundle_runtime.rs b/crates/plugins/tests/project_bundle_runtime.rs similarity index 99% rename from crates/core/tests/project_bundle_runtime.rs rename to crates/plugins/tests/project_bundle_runtime.rs index fe261e85..05c5b168 100644 --- a/crates/core/tests/project_bundle_runtime.rs +++ b/crates/plugins/tests/project_bundle_runtime.rs @@ -1,10 +1,11 @@ -use codetwo_core::app::events::PluginsChanged; -use codetwo_core::app::{ - AppConfig, CoreApp, PluginChangeRequest, PluginManagerError, PluginOverride, PluginScope, -}; use codetwo_kernel::{ CommandRealm, KernelError, PluginEntry, PluginOrigin, PluginScopeSupport, Status, }; +use codetwo_plugins::events::PluginsChanged; +use codetwo_plugins::testing::CoreAppTestExt; +use codetwo_plugins::{ + AppConfig, CoreApp, PluginChangeRequest, PluginManagerError, PluginOverride, PluginScope, +}; use serde_json::{json, Value}; use std::path::Path; diff --git a/crates/core/tests/project_plugin_graph.rs b/crates/plugins/tests/project_plugin_graph.rs similarity index 99% rename from crates/core/tests/project_plugin_graph.rs rename to crates/plugins/tests/project_plugin_graph.rs index dd73476a..814c22bf 100644 --- a/crates/core/tests/project_plugin_graph.rs +++ b/crates/plugins/tests/project_plugin_graph.rs @@ -1,12 +1,13 @@ -use codetwo_core::app::plugins::TerminalPlugin; -use codetwo_core::app::{ - AppConfig, CoreApp, PluginChangeRequest, PluginConfigStore, PluginManager, PluginManagerError, - PluginOverride, PluginScope, -}; use codetwo_kernel::{ async_trait, App, CommandRealm, Context, Loader, LoaderConfig, Plugin, PluginEntry, PluginMetadata, PluginRegistry, PluginResult, PluginScopeSupport, Service, }; +use codetwo_plugins::builtins::TerminalPlugin; +use codetwo_plugins::testing::CoreAppTestExt; +use codetwo_plugins::{ + AppConfig, CoreApp, PluginChangeRequest, PluginConfigStore, PluginManager, PluginManagerError, + PluginOverride, PluginScope, +}; use serde_json::{json, Value}; use std::path::Path; use std::sync::{Arc, Mutex}; diff --git a/crates/core/tests/tool_broker_adapter.rs b/crates/plugins/tests/tool_broker_adapter.rs similarity index 94% rename from crates/core/tests/tool_broker_adapter.rs rename to crates/plugins/tests/tool_broker_adapter.rs index f5137ced..c6a348e2 100644 --- a/crates/core/tests/tool_broker_adapter.rs +++ b/crates/plugins/tests/tool_broker_adapter.rs @@ -55,7 +55,7 @@ fn rust_adapter_consumes_the_bun_broker_plan() { #[tokio::test] async fn core_app_accepts_the_desktop_global_computer_use_selection() { let directory = tempfile::tempdir().unwrap(); - let app = codetwo_core::app::CoreApp::boot(codetwo_core::app::AppConfig::new(directory.path())) + let app = codetwo_plugins::CoreApp::boot(codetwo_plugins::AppConfig::new(directory.path())) .await .unwrap(); @@ -73,7 +73,7 @@ async fn core_app_accepts_the_desktop_global_computer_use_selection() { #[tokio::test] async fn core_app_persists_fail_closed_agent_browser_access() { let directory = tempfile::tempdir().unwrap(); - let app = codetwo_core::app::CoreApp::boot(codetwo_core::app::AppConfig::new(directory.path())) + let app = codetwo_plugins::CoreApp::boot(codetwo_plugins::AppConfig::new(directory.path())) .await .unwrap(); diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index aa3fa4d8..0d5ed780 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -20,7 +20,8 @@ name = "codetwo-agent" path = "src/bin/codetwo-agent.rs" [dependencies] -codetwo-core.workspace = true +codetwo-core = { workspace = true, features = ["terminal"] } +codetwo-plugins.workspace = true tokio.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/server/src/bin/codetwo-agent.rs b/crates/server/src/bin/codetwo-agent.rs index 6764e1ef..3361b16f 100644 --- a/crates/server/src/bin/codetwo-agent.rs +++ b/crates/server/src/bin/codetwo-agent.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; -use codetwo_core::app::{AppConfig, CanvasService, CoreApp, EngineService, EventBus, StoreService}; +use codetwo_plugins::{AppConfig, CanvasService, CoreApp, EngineService, EventBus, StoreService}; use codetwo_server::{ bind_and_serve_with_canvas, pairing_endpoints, pairing_qr_svg, pairing_url_for_endpoint, select_pairing_endpoint, AuthState, PairingEndpoint, diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 8db8b4fa..1920390b 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -9,7 +9,7 @@ use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; -use codetwo_core::app::{AppConfig, CanvasService, CoreApp, EngineService, EventBus, StoreService}; +use codetwo_plugins::{AppConfig, CanvasService, CoreApp, EngineService, EventBus, StoreService}; use codetwo_server::{bind_and_serve_with_canvas, print_pairing, AuthState, DEFAULT_PAIRING_TTL}; fn data_dir() -> PathBuf { diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index d8beb84a..83ed1460 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -12,6 +12,7 @@ path = "src/main.rs" [dependencies] codetwo-core.workspace = true +codetwo-plugins.workspace = true tokio.workspace = true uuid.workspace = true ratatui = "0.29" diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 1c9af1fe..a8d4bec7 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -1,7 +1,7 @@ //! C2 TUI entrypoint. Same core as the desktop app; ratatui renders it. //! //! The TUI does not build a C2 — it boots one. Storage, providers, the skill library and the -//! agent loop all come out of the plugin graph ([`codetwo_core::app`]), which is also why this +//! agent loop all come out of the plugin graph ([`codetwo_plugins`]), which is also why this //! file no longer knows the order any of them have to be constructed in. //! //! Two event sources feed one loop: a background thread reads terminal key events into a channel, @@ -14,9 +14,9 @@ use std::path::PathBuf; use std::time::Duration; use app::App; -use codetwo_core::app::{AppConfig, CoreApp, EngineService, EventBus, SkillService}; use codetwo_core::provider::{default_registry, home_dir}; use codetwo_core::Op; +use codetwo_plugins::{AppConfig, CoreApp, EngineService, EventBus, SkillService}; use ratatui::crossterm::event::{self, Event as CtEvent}; use ratatui::DefaultTerminal; @@ -30,10 +30,12 @@ fn data_dir() -> PathBuf { #[tokio::main] async fn main() -> std::io::Result<()> { let dir = data_dir(); - // A terminal frontend has no use for scenes, key bindings or the market — so it does not load - // them. Trimming the app is a config edit, not a build flag. + // A terminal frontend has no use for the scene graph, key bindings or the market — so it does + // not load them. Trimming the app is a config edit, not a build flag. let config = AppConfig::new(&dir) .without("scenes") + .without("scene-runtime") + .without("scene-commands") .without("keymap") .without("market"); let core = CoreApp::boot(config).await.map_err(std::io::Error::other)?; @@ -43,7 +45,9 @@ async fn main() -> std::io::Result<()> { .ok_or_else(|| std::io::Error::other(boot_failure(&core)))?; let engine = &*engine; // The skill library resolves the workspace the TUI was started in. - let skills = core.service::().ok_or_else(|| std::io::Error::other("no skills"))?; + let skills = core + .service::() + .ok_or_else(|| std::io::Error::other("no skills"))?; skills.reload(std::env::current_dir().ok().as_deref()); let skill_vec = skills.list(); let mut engine_rx = core @@ -85,7 +89,11 @@ fn boot_failure(core: &CoreApp) -> String { .filter(|scope| scope.error.is_some() || !scope.missing.is_empty()) .map(|scope| match scope.error { Some(error) => format!("{}: {error}", scope.plugin), - None => format!("{} is waiting for {}", scope.plugin, scope.missing.join(", ")), + None => format!( + "{} is waiting for {}", + scope.plugin, + scope.missing.join(", ") + ), }) .collect(); format!("the agent loop did not start — {}", blocked.join("; ")) diff --git a/docs/adr/0002-core-extension-boundary.md b/docs/adr/0002-core-extension-boundary.md index 5fe08d7e..1a418340 100644 --- a/docs/adr/0002-core-extension-boundary.md +++ b/docs/adr/0002-core-extension-boundary.md @@ -35,10 +35,16 @@ C2 uses these product terms: Extension API. Runtime metadata carries a `core`, `built_in`, or `extension` role so policy and UI do not infer product ownership from the shared lifecycle mechanism. -Core currently contains the path/store/event/provider foundations, plugin installation and policy, -Skills service required by the engine, the engine, the recovery/inspection surface, and extension -process supervision. Hosts may still compose a smaller graph explicitly; user or project extension -policy cannot remove a Core module from a graph the host chose to provide. +Physical ownership follows the same distinction: + +- `codetwo-core` contains product domain and execution behavior, with no dependency on the Kernel + or plugin composition crate; +- `codetwo-kernel` contains the product-agnostic runtime-module lifecycle; +- `codetwo-plugins` is the composition root that depends on both, owns built-in adapters, Bundle + installation and policy, the recovery/inspection surface, and extension process supervision. + +Hosts may still compose a smaller graph explicitly; user or project extension policy cannot remove +a Core-role module from a graph the host chose to provide. The existing `CoreApp::call` and realm-aware command path remains the one host-facing transport. Commands are internal by default. A process extension receives and may call only commands that Core diff --git a/docs/architecture.md b/docs/architecture.md index b94d6ba9..e6ec212f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,16 +2,19 @@ C2 drives existing coding CLIs (Claude Code, OpenAI Codex, Grok) over the **Agent Client Protocol (ACP)** and presents them through a **document-first** UI. The desktop, TUI, and server all -use the same Rust core and Plugin Kernel. Electrobun is a desktop-shell adapter, not a second -business runtime. +compose the same plugin-independent Rust Core through one plugin runtime. Electrobun is a +desktop-shell adapter, not a second business runtime. ## Why this shape - **ACP is the common abstraction.** JSON-RPC over stdio, with entry points for all three providers (Grok natively; Claude Code & Codex via official adapters). We implement the client loop once and treat each backend as a launch command. -- **The Rust core is the single implementation.** The TUI and server link it directly. The desktop - packages `codetwo-desktop-host`, which boots the same `CoreApp` graph plus desktop-owned +- **Core has one direction of dependency.** `codetwo-core` owns product behavior and knows nothing + about plugin lifecycle, extension Bundles, or host protocols. `codetwo-plugins` depends on Core + and the generic Kernel, adapting Core capabilities into the shared `CoreApp` graph. The TUI, + server, and desktop host depend on that composition layer. The desktop packages + `codetwo-desktop-host`, which boots the same graph plus desktop-owned automation, device-sync, event, language-server, and remote adapters. Bun owns windows, dialogs, updates, and the narrow JSON-lines process transport. @@ -19,9 +22,9 @@ business runtime. Everything below is a kernel **runtime module**. `crates/kernel` is a Rust port of [cordis](https://github.com/cordiverse/cordis): contexts, services published by name, declared -injections, and scopes that undo everything a plugin did when it unloads. `crates/core/src/app` -defines C2's subsystems as plugins over it, and `CoreApp::boot(AppConfig)` assembles them from -config rather than from a constructor. +injections, and scopes that undo everything a plugin did when it unloads. `crates/plugins` owns the +composition root, built-in adapters, extension Bundle management, and process protocol; +`CoreApp::boot(AppConfig)` assembles them from config rather than from a constructor. That shared Rust trait is an implementation mechanism, not the public plugin contract. Product policy distinguishes non-user-manageable **Core**, optional C2-owned **built-in features**, and @@ -43,21 +46,36 @@ command invocation starts the process. Spec: ## Layers ``` - crates/kernel (the plugin runtime — cordis in Rust) - crates/core (Rust library — no UI) - ┌──────────────────────────────────────────────────────────────┐ - │ ACP, providers, sessions, skills, policy, events and plugins │ - └──────────────────┬───────────────────────┬───────────────────┘ - │ links directly │ links directly - crates/tui (ratatui) crates/server (Axum) - - apps/desktop/src-host (Rust CoreApp + desktop host plugins) + crates/core crates/kernel + product domain and execution generic plugin lifecycle + │ │ + └──────────┐ ┌────────────┘ + ▼ ▼ + crates/plugins + built-in adapters, CoreApp, Bundles and protocol + │ │ │ + ▼ ▼ ▼ + crates/tui crates/server apps/desktop/src-host + (CoreApp + desktop host modules) │ versioned JSON-lines commands + events - apps/desktop/src/electrobun (Bun window/dialog/update adapter) - │ one typed Electrobun `call` RPC - apps/desktop/src (React + Vite + BlockNote + sandboxed webviews) + apps/desktop/src/electrobun + browser/electrobun.ts (platform implementation) + │ + apps/desktop/src/container.ts (the renderer's only desktop-shell port) + │ typed capabilities; no Electrobun imports above this line + apps/desktop/src/bridge.ts + product content (React + Vite + BlockNote) ``` +The forbidden edges are part of the design: `codetwo-core` must not depend on +`codetwo-kernel` or `codetwo-plugins`, and `codetwo-kernel` remains product-agnostic. Shared +composition belongs in `codetwo-plugins`; a host may additionally provide platform-specific +Kernel modules, but those modules must not leak back into Core. + +The desktop follows the same rule inside the renderer. `container.ts` owns the shell-facing import +surface: RPC transport, dialogs, native menus, updates, appshots, pets, and embedded webviews. +`bridge.ts` owns product commands and browser fallbacks. Product components may depend on those two +content-facing modules, but they do not import Electrobun implementations directly. This keeps a +shell replacement or browser-only renderer from spreading conditional native code through the UI. + ## Device synchronization Device sync follows the same ownership boundary. `codetwo-core` owns the versioned document, diff --git a/docs/plugin-protocol.md b/docs/plugin-protocol.md index 78937aa1..d6d4ee93 100644 --- a/docs/plugin-protocol.md +++ b/docs/plugin-protocol.md @@ -144,7 +144,7 @@ You receive only the events you name in `events`. The host publishes: This list is the contract. Typed Rust events do not cross a pipe, so each entry is a deliberate decision to expose one — see `publish_host_events` in -`crates/core/src/app/plugins/extensions.rs`. +`crates/plugins/src/app/plugins/extensions.rs`. Because 1.1 activation is command-driven, event subscriptions begin only after the first command has successfully initialized the process; events emitted while the runtime is dormant are not buffered or replayed. diff --git a/docs/plugin-standard.md b/docs/plugin-standard.md index 29814aa8..e7c0adc0 100644 --- a/docs/plugin-standard.md +++ b/docs/plugin-standard.md @@ -172,7 +172,7 @@ package or ship renderer code. Validate the same directory that will be committe ```sh cd apps/desktop bun run plugin:validate ../../packs/hello-runtime -cargo run -p codetwo-core --example validate_bundle -- ../../packs/hello-runtime +cargo run -p codetwo-plugins --example validate_bundle -- ../../packs/hello-runtime ``` The Bun command is a fast manifest preflight. The Rust command uses the desktop installer's @@ -358,9 +358,9 @@ A change is plugin-conformant only when all applicable statements are true: Useful validation commands: ```sh -cargo test -p codetwo-core plugin --lib -cargo test -p codetwo-core --test plugin_protocol -cargo test -p codetwo-core --test project_bundle_runtime +cargo test -p codetwo-plugins +cargo test -p codetwo-plugins --test plugin_protocol +cargo test -p codetwo-plugins --test project_bundle_runtime cd apps/desktop && bun test && bun run build cd website && bun run docs:build ``` diff --git a/docs/plugins.md b/docs/plugins.md index 456e319a..b1ad482a 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -1,7 +1,8 @@ -# Core runtime modules and plugins +# Runtime modules and plugins -C2 Core is implemented as a runtime-module graph. This document explains that internal mechanism, -how Core and host modules fit together, and where the public extension boundary begins. +C2's host runtime is implemented as a runtime-module graph in `codetwo-plugins`. This document +explains that internal mechanism, how plugin-independent Core capabilities become runtime modules, +and where the public extension boundary begins. For the normative package, naming, lifecycle, scope, security, versioning, and host-capability rules, see the [C2 Plugin Standard 1.1.0](plugin-standard.md). This document focuses on the graph's @@ -12,6 +13,13 @@ The internal model is [cordis](https://github.com/cordiverse/cordis)', ported to extension points bolted on; it is a graph of plugins that happens to boot. We agree, and this is what taking that seriously looks like in a Rust codebase. +The crate seam is deliberate: `codetwo-core` owns product behavior, `codetwo-kernel` owns generic +lifecycle machinery, and `codetwo-plugins` is the shared composition crate that depends on both. +Host binaries may also depend on both when they contribute platform-specific runtime modules; +shared composition still belongs in `codetwo-plugins`. Built-in runtime modules are adapters over +Core; Bundle parsing, policy, process supervision, and the public protocol do not leak back into +Core. + In product language, `codetwo_kernel::Plugin` is a **runtime module**, not automatically an installable plugin. The catalog assigns one of three roles: @@ -107,7 +115,7 @@ disabling `paths` or another foundation plugin cannot strand the user without a ## Booting ```rust -use codetwo_core::app::{AppConfig, CoreApp}; +use codetwo_plugins::{AppConfig, CoreApp}; let app = CoreApp::boot(AppConfig::new("~/.codetwo")).await?; let status = app.call("git.status", json!({ "cwd": "/repo" })).await?; @@ -164,7 +172,7 @@ impl Plugin for IssuesPlugin { } ``` -Register it in `crates/core/src/app/plugins/mod.rs` (`builtin_registry`) and add its name to +Register it in `crates/plugins/src/app/plugins/mod.rs` (`builtin_registry`) and add its name to `BUILTIN`. Add it to `CORE` only when host ownership is required to preserve a product, data, or security invariant. That is the whole integration. @@ -289,7 +297,7 @@ becomes eligible only once the user marks the bundle **trusted**. Its static Man then ready, while the process starts on the first invocation — installing still executes nothing. See [`docs/plugin-protocol.md`](plugin-protocol.md) for the spec and a working plugin in forty lines. Runtime, safe UI actions, and language servers are distributed together from that same bundle root; -run `cargo run -p codetwo-core --example validate_bundle -- ` before publishing it or +run `cargo run -p codetwo-plugins --example validate_bundle -- ` before publishing it or installing it from GitHub. The Bun `plugin:validate` command remains a faster manifest-only preflight. @@ -325,7 +333,7 @@ surface are decided at runtime. External command surfaces are static Manifest co than process-discovered UI, while a host can register compiled modules of its own at boot: ```rust -let mut registry = codetwo_core::app::plugins::builtin_registry(); +let mut registry = codetwo_plugins::builtins::builtin_registry(); registry.register(|| MyPlugin); // add registry.register_arc(Box::new(|| my_engine)); // or replace a built-in by name CoreApp::boot_with(config, registry).await?; @@ -343,7 +351,7 @@ They meet in `plugin-hub`, and the terms remain separate: - **Runtime module** — code that runs under the internal graph lifecycle and contributes commands or services. Compiled modules implement `codetwo_kernel::Plugin`; external extensions use the [plugin protocol](plugin-protocol.md) and only the public Extension API. -- **Installed bundle** (`codetwo_core::plugin::InstalledPlugin`) — a package users install from +- **Installed bundle** (`codetwo_plugins::bundle::InstalledPlugin`) — a package users install from GitHub. Installing it executes nothing; it contributes skills, subagent definitions, MCP server definitions, scenes, and scaffolds. See `docs/architecture.md`. @@ -355,10 +363,10 @@ Extension API. The application migration is complete: -- `codetwo-core` boots the built-in graph through `CoreApp::boot(AppConfig)`. Worktrees, +- `codetwo-plugins` boots the built-in graph through `CoreApp::boot(AppConfig)`. Worktrees, workspace I/O and search, projects, artifacts, canvas/document compilation, terminal/PTY/tmux, usage, voice, issues/delegation, scene commands, pipelines, memory, Git, market, skills and the - engine all contribute commands from plugin scopes. + engine all adapt `codetwo-core` capabilities into commands from plugin scopes. - `codetwo-tui` boots that graph and consumes its typed event and engine services. It trims plugins it does not need through `AppConfig` rather than constructing a separate application. - The standalone `codetwo-server` also boots `CoreApp`, then gives the graph's engine, store, @@ -370,10 +378,10 @@ The application migration is complete: trusted and enabled extension adapters ready, registers their static commands into the same command seam, and lazily creates isolated child processes and command realms for project-capable Bundles. -- The renderer exposes one typed `call` request. Electrobun relays it to one versioned JSON-lines - `call` method on the bundled Rust host; host events return over the same connection. A protocol - mismatch or failed Kernel startup stops desktop startup instead of falling back to another - implementation. +- Renderer content reaches Electrobun only through `src/container.ts`. Its one typed `call` request + is relayed to one versioned JSON-lines `call` method on the bundled Rust host; host events return + over the same connection. A protocol mismatch or failed Kernel startup stops desktop startup + instead of falling back to another implementation. Desktop event envelopes remain host plumbing rather than a business API. Manual browser tabs persist in the renderer and render as sandboxed `` elements. @@ -393,7 +401,7 @@ requires; restoring it requires an upstream-capable adapter, not a pretend parti Desktop-only registration follows the same loader contract: ```rust -let mut registry = codetwo_core::app::plugins::builtin_registry(); +let mut registry = codetwo_plugins::builtins::builtin_registry(); let events = events.clone(); registry.register(move || HostEventsPlugin::new(events.clone())); let config = AppConfig::new(&data_dir).with("desktop-events", PluginEntry::default()); diff --git a/docs/research/vscode-extension-architecture-2026-08-26.md b/docs/research/vscode-extension-architecture-2026-08-26.md index f3d81560..933a14fe 100644 --- a/docs/research/vscode-extension-architecture-2026-08-26.md +++ b/docs/research/vscode-extension-architecture-2026-08-26.md @@ -160,19 +160,19 @@ eligible = installed + enabled + bundle-trusted + workspace-policy-allowed **[仓库事实]** 当前 [C2 Plugin Standard](../plugin-standard.md) 已把 Bundle、Contribution、Runtime module、Host adapter、Policy 分成五个概念;安装不执行代码,runtime/LSP 需要 enabled + trusted;项目 runtime 有独立 graph/process/command realm/data dir;第三方 UI 只能提交宿主渲染 descriptor。这些都应保留。 -**[仓库事实]** 第三方 process bundle 会转换成同一 loader 中的 `bundle:` factory,并按 bundle/realm 单独起进程:[bundle runtime](../../crates/core/src/app/bundle_runtime.rs)。当前 marketplace parser 已支持 root `marketplace.json`、逐条错误隔离、local/GitHub/Git/npm/archive source shape,以及 Git SHA/archive SHA-256 字段:[marketplace parser](../../crates/core/src/plugin_marketplace.rs)。`c2-plugins` 应成为这套格式的 canonical catalog,而不是新造第二套插件系统。 +**[仓库事实]** 第三方 process bundle 会转换成同一 loader 中的 `bundle:` factory,并按 bundle/realm 单独起进程:[bundle runtime](../../crates/plugins/src/app/bundle_runtime.rs)。当前 marketplace parser 已支持 root `marketplace.json`、逐条错误隔离、local/GitHub/Git/npm/archive source shape,以及 Git SHA/archive SHA-256 字段:[marketplace parser](../../crates/plugins/src/marketplace.rs)。`c2-plugins` 应成为这套格式的 canonical catalog,而不是新造第二套插件系统。 ### 5.2 P0 边界缺口 #### A. 内部模块机制仍被当成公共插件模型 -**[仓库事实]** 当前 Core 文档说“每个 subsystem 都是 Plugin”,并写明“一个插件的 commands 就是 app 的 public API”:[CoreApp module](../../crates/core/src/app/mod.rs)。`BUILTIN` 目录又把 paths/store/bus/providers/engine/plugin-hub 等基础设施与 Git、voice、market、skills 等可选能力一起注册和展示:[built-in registry](../../crates/core/src/app/plugins/mod.rs)。 +**[仓库事实]** 当前 Core 文档说“每个 subsystem 都是 Plugin”,并写明“一个插件的 commands 就是 app 的 public API”:[CoreApp module](../../crates/plugins/src/app/mod.rs)。`BUILTIN` 目录又把 paths/store/bus/providers/engine/plugin-hub 等基础设施与 Git、voice、market、skills 等可选能力一起注册和展示:[built-in registry](../../crates/plugins/src/app/plugins/mod.rs)。 **[推断]** 内部统一生命周期很有价值,但它不应决定外部 API 和用户词汇。否则“关闭一个插件”可能意味着关闭数据库/政策恢复面,“开发插件”又可能被理解成实现 Rust trait、写 JSON-RPC 进程或只放一个 Skill。 #### B. 第三方 runtime 能看到并调用 realm 内全部 Core 命令 -**[仓库事实]** `initialize.host.commands` 当前由 `ctx.runtime().commands()` 全量生成;协议注释明确写着插件可以回调任意一个命令。`command/call` 直接进入同一 realm 的普通 command path:[protocol host surface](../../crates/core/src/app/protocol/mod.rs)、[wire contract](../../crates/core/src/app/protocol/wire.rs)、[request dispatch](../../crates/core/src/app/protocol/peer.rs)。 +**[仓库事实]** `initialize.host.commands` 当前由 `ctx.runtime().commands()` 全量生成;协议注释明确写着插件可以回调任意一个命令。`command/call` 直接进入同一 realm 的普通 command path:[protocol host surface](../../crates/plugins/src/app/protocol/mod.rs)、[wire contract](../../crates/plugins/src/app/protocol/wire.rs)、[request dispatch](../../crates/plugins/src/app/protocol/peer.rs)。 **[推断]** realm 隔离阻止跨项目 fallback,但没有形成 Core/private 与 Extension/public 的边界。任何新增内部 command 都会无意扩大第三方 API 与权限面,也让 Core 难以重构。 @@ -184,7 +184,7 @@ eligible = installed + enabled + bundle-trusted + workspace-policy-allowed #### D. Marketplace 是单版本目录,不是版本解析与供应链 -**[仓库事实]** 当前 `MarketplacePlugin` 每项只有一个 `version + source`;没有 publisher identity、`engines.codetwo`、target platform、channel、releasedAt、artifact signature、yank/deprecation/advisory 或多版本解析。GitHub `reference` 和 `sha` 都可选;archive `sha256` 也可选:[marketplace parser](../../crates/core/src/plugin_marketplace.rs)。 +**[仓库事实]** 当前 `MarketplacePlugin` 每项只有一个 `version + source`;没有 publisher identity、`engines.codetwo`、target platform、channel、releasedAt、artifact signature、yank/deprecation/advisory 或多版本解析。GitHub `reference` 和 `sha` 都可选;archive `sha256` 也可选:[marketplace parser](../../crates/plugins/src/marketplace.rs)。 **[推断]** 这足够做本地目录/预览,不足以支撑默认社区更新渠道。 diff --git a/docs/roadmap.md b/docs/roadmap.md index 5351c60b..754d98e8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -114,7 +114,7 @@ bridge from IDE toward office work. Packaging only; the scene format is already invalid files skipped non-fatally), the scene loader reads installed plugins' `bundle/scenes/`, and `scene::export_skill_md` covers the §Interop SKILL.md export. Scenes/pipelines as plugin components (schema-validated, pure data — the hub pipeline in -`crates/core/src/plugin.rs` already models component counts), plus SKILL.md export for +`crates/plugins/src/bundle.rs` already models component counts), plus SKILL.md export for skill-only hosts as specified in `docs/scenes.md` §Interop. ## Dependencies at a glance diff --git a/docs/superpowers/plans/2026-08-26-plugin-hot-reload.md b/docs/superpowers/plans/2026-08-26-plugin-hot-reload.md index b239a20d..a242b65c 100644 --- a/docs/superpowers/plans/2026-08-26-plugin-hot-reload.md +++ b/docs/superpowers/plans/2026-08-26-plugin-hot-reload.md @@ -13,8 +13,8 @@ ### Task 1: Targeted dynamic Bundle reload **Files:** -- Modify: `crates/core/src/app/plugin_manager.rs` -- Test: `crates/core/tests/project_bundle_runtime.rs` +- Modify: `crates/plugins/src/app/plugin_manager.rs` +- Test: `crates/plugins/tests/project_bundle_runtime.rs` - [ ] **Step 1: Add a failing integration test** @@ -37,7 +37,7 @@ assert_eq!(before_stable["pid"], after_stable["pid"]); - [ ] **Step 2: Run the focused test and confirm failure** -Run: `cargo test -p codetwo-core --test project_bundle_runtime targeted_bundle_reload` +Run: `cargo test -p codetwo-plugins --test project_bundle_runtime targeted_bundle_reload` Expected: compilation fails because `reload_installed_bundles` does not exist. @@ -67,7 +67,7 @@ forced set with fingerprint-derived changes before calling each loader's `reconc - [ ] **Step 4: Run the focused test** -Run: `cargo test -p codetwo-core --test project_bundle_runtime targeted_bundle_reload` +Run: `cargo test -p codetwo-plugins --test project_bundle_runtime targeted_bundle_reload` Expected: PASS; only the requested Bundle pid changes. @@ -76,10 +76,10 @@ Expected: PASS; only the requested Bundle pid changes. **Files:** - Modify: `crates/core/Cargo.toml` - Modify: `Cargo.lock` -- Create: `crates/core/src/app/plugin_development.rs` -- Modify: `crates/core/src/app/mod.rs` -- Modify: `crates/core/src/app/plugins/hub.rs` -- Test: `crates/core/tests/project_bundle_runtime.rs` +- Create: `crates/plugins/src/app/plugin_development.rs` +- Modify: `crates/plugins/src/app/mod.rs` +- Modify: `crates/plugins/src/app/plugins/hub.rs` +- Test: `crates/plugins/tests/project_bundle_runtime.rs` - [ ] **Step 1: Add failing command and watcher tests** @@ -102,7 +102,7 @@ assert_ne!(before["pid"], changed["pid"]); - [ ] **Step 2: Run the tests and confirm failure** -Run: `cargo test -p codetwo-core --test project_bundle_runtime developer_` +Run: `cargo test -p codetwo-plugins --test project_bundle_runtime developer_` Expected: command-not-found failures for the three development commands. @@ -163,7 +163,7 @@ reconcile through `try_lock`. - [ ] **Step 5: Run core verification** -Run: `cargo test -p codetwo-core --test project_bundle_runtime developer_` +Run: `cargo test -p codetwo-plugins --test project_bundle_runtime developer_` Expected: PASS for persistence, manual reload, automatic reload, disabled mode, and isolation. @@ -304,7 +304,7 @@ Developer settings path to `docs/plugins.md`. Run: `cargo fmt --all -- --check` -Run: `cargo test -p codetwo-core --test project_bundle_runtime` +Run: `cargo test -p codetwo-plugins --test project_bundle_runtime` Run: `cargo test -p codetwo-desktop-host` From ac5225cc999c975559c259d00aef22105656bbc0 Mon Sep 17 00:00:00 2001 From: idevlab Date: Fri, 28 Aug 2026 20:50:28 +0800 Subject: [PATCH 2/2] test: normalize desktop boundary paths --- apps/desktop/tests/containerBoundary.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/tests/containerBoundary.test.ts b/apps/desktop/tests/containerBoundary.test.ts index d706c8d9..eb339ffd 100644 --- a/apps/desktop/tests/containerBoundary.test.ts +++ b/apps/desktop/tests/containerBoundary.test.ts @@ -13,7 +13,7 @@ function sourceFiles(directory: string): string[] { } function isDesktopImplementation(path: string): boolean { - const name = relative(sourceRoot, path); + const name = relative(sourceRoot, path).replaceAll("\\", "/"); return ( name === "container.ts" || name === "browser/electrobun.ts"