From ca0332483887a1adb685875f922bfe605a619ee2 Mon Sep 17 00:00:00 2001 From: Nicolas Cantu Date: Mon, 6 Apr 2026 13:03:34 +0200 Subject: [PATCH] feat: update Lapce patch series for Smart IDE panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Motivations:** - Keep the Lapce fork changes replayable via patch files in the monorepo. - Expose a minimal Smart IDE cockpit UX (agents, runs, services, connection) inside Lapce. **Root causes:** - N/A **Correctifs:** - ia-dev-gateway: parse YAML frontmatter (name/description) from agent markdown files. **Evolutions:** - Export new Lapce patch files for the Smart IDE panel + connection helpers. - Add documentation for the Smart IDE panel. **Pages affectées:** - N/A --- VERSION | 2 +- docs/features/smart-ide-panel.md | 67 + ...DE-intents-palette-and-scratch-actio.patch | 613 +++++ ...002-feat-add-Smart-IDE-cockpit-panel.patch | 2278 +++++++++++++++++ .../0003-fix-handle-notify-send-errors.patch | 30 + patches/lapce/series | 3 + services/ia-dev-gateway/src/server.ts | 68 +- 7 files changed, 3056 insertions(+), 5 deletions(-) create mode 100644 docs/features/smart-ide-panel.md create mode 100644 patches/lapce/0001-feat-add-Smart-IDE-intents-palette-and-scratch-actio.patch create mode 100644 patches/lapce/0002-feat-add-Smart-IDE-cockpit-panel.patch create mode 100644 patches/lapce/0003-fix-handle-notify-send-errors.patch diff --git a/VERSION b/VERSION index 5a5831a..d169b2f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.7 +0.0.8 diff --git a/docs/features/smart-ide-panel.md b/docs/features/smart-ide-panel.md new file mode 100644 index 0000000..c21e907 --- /dev/null +++ b/docs/features/smart-ide-panel.md @@ -0,0 +1,67 @@ +# Smart IDE panel (Lapce) + +The **Smart IDE** panel is a cockpit inside Lapce to: + +- browse the `ia_dev` agent catalog, +- run a small set of operational agents (via `ia-dev-gateway`), +- follow run status + logs (SSE), +- open related web services (preview, AnythingLLM, local-office), +- check connectivity and generate an SSH tunnel command. + +## Open the panel + +- **Command palette**: `Smart IDE: Open Panel` +- **Pinned actions shortcuts**: `Smart IDE: Run Pinned Action 1..9` (bind your own keys) + +The panel is registered as a Lapce **left-top** panel with the **lightbulb** icon. + +## Tabs + +### Agents + +- **Refresh** fetches `GET /v1/agents` from `ia-dev-gateway`. +- **Run** starts a run (`POST /v1/runs`) when the agent is runnable. +- **Open** opens the agent Markdown definition under: + - `services/ia_dev/.smartIde/agents/.md` + +### Runs + +- Shows recent runs started from the panel. +- Streams events from `GET /v1/runs//events` and appends: + - stdout/stderr (script logs), + - lifecycle events (started/completed/failed). + +### Services + +Minimal “open in browser” hub: + +- **Preview**: read from `projects//conf.json`: + - `smart_ide.preview_urls.` +- **AnythingLLM**: `http://127.0.0.1:3001/` (requires tunnel mode `all`) +- **local-office**: `http://127.0.0.1:8000/` (requires tunnel mode `all`) + +### Connection + +- **Health checks**: + - `GET /health` on orchestrator, `ia-dev-gateway`, and tools-bridge. +- **SSH tunnel plan**: + - Runs `scripts/smart-ide-ssh-tunnel-plan.sh --json` + - Displays a copyable command (shell-escaped argv) and the hint message. + +## Configuration keys + +The panel reads settings from the Lapce config section `[smart-ide]`: + +- `orchestrator_url`, `orchestrator_token` +- `ia_dev_gateway_url`, `ia_dev_gateway_token` +- `tools_bridge_url`, `tools_bridge_token` +- `project_id`, `env` +- `pinned_actions` (CSV) + +Project context resolution (first match): + +- settings (`project_id`, `env`) +- `projects/active-project.json` (gitignored) + +Tokens must stay in user settings (never committed). + diff --git a/patches/lapce/0001-feat-add-Smart-IDE-intents-palette-and-scratch-actio.patch b/patches/lapce/0001-feat-add-Smart-IDE-intents-palette-and-scratch-actio.patch new file mode 100644 index 0000000..55beb93 --- /dev/null +++ b/patches/lapce/0001-feat-add-Smart-IDE-intents-palette-and-scratch-actio.patch @@ -0,0 +1,613 @@ +From a14d933b0b2c0016a9257912ee9bbfeea0ebb5ad Mon Sep 17 00:00:00 2001 +From: Nicolas Cantu +Date: Sun, 5 Apr 2026 23:11:33 +0200 +Subject: [PATCH 1/3] feat: add Smart IDE intents palette and scratch actions + +- Add Smart IDE settings (orchestrator + ia-dev-gateway URLs/tokens) +- Add '!'-prefixed palette kind to execute orchestrator intents +- Add commands to fetch orchestrator timeline and list ia-dev agents into scratch docs + +diff --git a/lapce-app/src/app.rs b/lapce-app/src/app.rs +index 0676963..6fcf580 100644 +--- a/lapce-app/src/app.rs ++++ b/lapce-app/src/app.rs +@@ -2558,6 +2558,7 @@ fn palette_item( + ) + } + PaletteItemContent::Line { .. } ++ | PaletteItemContent::SmartIdeIntent { .. } + | PaletteItemContent::Workspace { .. } + | PaletteItemContent::SshHost { .. } + | PaletteItemContent::Language { .. } +diff --git a/lapce-app/src/command.rs b/lapce-app/src/command.rs +index c0cc3b8..25f9027 100644 +--- a/lapce-app/src/command.rs ++++ b/lapce-app/src/command.rs +@@ -402,6 +402,10 @@ pub enum LapceWorkbenchCommand { + #[strum(serialize = "palette.palette_help_and_file")] + PaletteHelpAndFile, + ++ #[strum(message = "Smart IDE: Run Intent")] ++ #[strum(serialize = "palette.smart_ide_intent")] ++ PaletteSmartIdeIntent, ++ + #[strum(message = "Run and Debug Restart Current Running")] + #[strum(serialize = "palette.run_and_debug_restart")] + RunAndDebugRestart, +@@ -410,6 +414,14 @@ pub enum LapceWorkbenchCommand { + #[strum(serialize = "palette.run_and_debug_stop")] + RunAndDebugStop, + ++ #[strum(message = "Smart IDE: Open Timeline")] ++ #[strum(serialize = "smart_ide.timeline")] ++ SmartIdeTimeline, ++ ++ #[strum(message = "Smart IDE: List Agents")] ++ #[strum(serialize = "smart_ide.agents.list")] ++ SmartIdeListAgents, ++ + #[strum(serialize = "source_control.checkout_reference")] + CheckoutReference, + +diff --git a/lapce-app/src/lib.rs b/lapce-app/src/lib.rs +index 43a55ce..ea3c659 100644 +--- a/lapce-app/src/lib.rs ++++ b/lapce-app/src/lib.rs +@@ -33,6 +33,7 @@ + pub mod settings; + pub mod snippet; + pub mod source_control; ++pub mod smart_ide; + pub mod status; + pub mod terminal; + pub mod text_area; +diff --git a/lapce-app/src/main_split.rs b/lapce-app/src/main_split.rs +index d698754..fae3b48 100644 +--- a/lapce-app/src/main_split.rs ++++ b/lapce-app/src/main_split.rs +@@ -2429,6 +2429,46 @@ pub fn open_keymap(&self) { + self.get_editor_tab_child(EditorTabChildSource::Keymap, false, false); + } + ++ pub fn open_named_scratch(&self, name: &str, content: String) -> Rc { ++ let name = name.to_string(); ++ let existing = ++ self.scratch_docs.with_untracked(|scratch_docs| scratch_docs.get(&name).cloned()); ++ ++ let doc = if let Some(doc) = existing { ++ doc ++ } else { ++ let doc_content = DocContent::Scratch { ++ id: BufferId::next(), ++ name: name.clone(), ++ }; ++ let doc = Rc::new(Doc::new_content( ++ self.scope, ++ doc_content, ++ self.editors, ++ self.common.clone(), ++ )); ++ self.scratch_docs.update(|scratch_docs| { ++ scratch_docs.insert(name.clone(), doc.clone()); ++ }); ++ doc ++ }; ++ ++ doc.reload(Rope::from(content), true); ++ doc.set_syntax(Syntax::init(&PathBuf::from(name.clone()))); ++ doc.trigger_syntax_change(None); ++ ++ self.get_editor_tab_child( ++ EditorTabChildSource::Editor { ++ path: PathBuf::from(name), ++ doc: doc.clone(), ++ }, ++ false, ++ false, ++ ); ++ ++ doc ++ } ++ + pub fn new_file(&self) -> EditorTabChild { + self.get_editor_tab_child(EditorTabChildSource::NewFileEditor, false, false) + } +diff --git a/lapce-app/src/palette.rs b/lapce-app/src/palette.rs +index fb85013..699dfaa 100644 +--- a/lapce-app/src/palette.rs ++++ b/lapce-app/src/palette.rs +@@ -11,7 +11,7 @@ + time::Instant, + }; + +-use anyhow::Result; ++use anyhow::{Context, Result}; + use floem::{ + ext_event::{create_ext_action, create_signal_from_channel}, + keyboard::Modifiers, +@@ -344,6 +344,9 @@ pub fn run(&self, kind: PaletteKind) { + /// Get the placeholder text to use in the palette input field. + pub fn placeholder_text(&self) -> &'static str { + match self.kind.get() { ++ PaletteKind::SmartIdeIntent => { ++ "Type an intent (e.g. chat.local, git.clone) or select one below" ++ } + PaletteKind::SshHost => { + "Type [user@]host or select a previously connected workspace below" + } +@@ -390,6 +393,9 @@ fn run_inner(&self, kind: PaletteKind) { + PaletteKind::WorkspaceSymbol => { + self.get_workspace_symbols(); + } ++ PaletteKind::SmartIdeIntent => { ++ self.get_smart_ide_intents(); ++ } + PaletteKind::SshHost => { + self.get_ssh_hosts(); + } +@@ -598,6 +604,93 @@ fn get_commands(&self) { + self.items.set(items); + } + ++ fn get_smart_ide_intents(&self) { ++ const INTENTS: &[(&str, &str)] = &[ ++ ("chat.local", "Chat (LLM/RAG via orchestrator)"), ++ ("code.complete", "Code completion (LLM via orchestrator)"), ++ ("rag.query", "RAG query (AnythingLLM via orchestrator)"), ++ ("git.clone", "Clone repository (repos-devtools)"), ++ ("search.regex", "Regex search (agent-regex-search-api)"), ++ ("extract.entities", "Entity extraction (langextract-api)"), ++ ("doc.office.upload", "Upload office document (local-office)"), ++ ("tools.registry", "List available tools (tools-bridge)"), ++ ("tools.carbonyl.plan", "Open Carbonyl preview plan (tools-bridge)"), ++ ("tools.pageindex.run", "Run PageIndex (tools-bridge)"), ++ ("tools.chandra.ocr", "Run OCR (tools-bridge)"), ++ ("agent.run", "Run an ia_dev agent (ia-dev-gateway)"), ++ ]; ++ ++ let items = INTENTS ++ .iter() ++ .map(|(intent, label)| PaletteItem { ++ content: PaletteItemContent::SmartIdeIntent { ++ intent: (*intent).to_string(), ++ }, ++ filter_text: format!("{intent} — {label}"), ++ score: 0, ++ indices: Vec::new(), ++ }) ++ .collect(); ++ ++ self.items.set(items); ++ } ++ ++ fn smart_ide_execute_intent(&self, intent: String) { ++ let intent = intent.trim().to_string(); ++ if intent.is_empty() { ++ self.common.internal_command.send(InternalCommand::ShowAlert { ++ title: "Smart IDE".to_string(), ++ msg: "Intent is empty.".to_string(), ++ buttons: Vec::new(), ++ }); ++ return; ++ } ++ ++ let config = self.common.config.get_untracked(); ++ if let Err(err) = crate::smart_ide::orchestrator_token(&config) { ++ self.common.internal_command.send(InternalCommand::ShowAlert { ++ title: "Smart IDE".to_string(), ++ msg: err.to_string(), ++ buttons: Vec::new(), ++ }); ++ return; ++ } ++ ++ self.main_split.open_named_scratch( ++ "SmartIDE-output.json", ++ format!("Running intent: {intent}\n"), ++ ); ++ ++ let main_split = self.main_split.clone(); ++ let send = create_ext_action(self.common.scope, move |result: Result| { ++ match result { ++ Ok(text) => { ++ main_split.open_named_scratch("SmartIDE-output.json", text); ++ } ++ Err(err) => { ++ main_split.open_named_scratch( ++ "SmartIDE-output.json", ++ format!("Error:\n{err}"), ++ ); ++ } ++ } ++ }); ++ ++ let intent_for_thread = intent.clone(); ++ std::thread::Builder::new() ++ .name("SmartIdeExecuteIntent".to_owned()) ++ .spawn(move || { ++ let run = || -> Result { ++ let resp = ++ crate::smart_ide::orchestrator_execute(&config, &intent_for_thread)?; ++ serde_json::to_string_pretty(&resp) ++ .context("Failed to format JSON response") ++ }; ++ send(run()); ++ }) ++ .ok(); ++ } ++ + /// Initialize the palette with all the available workspaces, local and remote. + fn get_workspaces(&self) { + let db: Arc = use_context().unwrap(); +@@ -1188,6 +1281,9 @@ fn select(&self) { + PaletteItemContent::Command { cmd } => { + self.common.lapce_command.send(cmd.clone()); + } ++ PaletteItemContent::SmartIdeIntent { intent } => { ++ self.smart_ide_execute_intent(intent.clone()); ++ } + PaletteItemContent::Workspace { workspace } => { + self.common.window_common.window_command.send( + WindowCommand::SetWorkspace { +@@ -1342,6 +1438,9 @@ fn select(&self) { + }, + }, + ); ++ } else if self.kind.get_untracked() == PaletteKind::SmartIdeIntent { ++ let input = self.input.with_untracked(|input| input.input.clone()); ++ self.smart_ide_execute_intent(input); + } + } + +@@ -1387,6 +1486,7 @@ fn preview(&self) { + ); + } + PaletteItemContent::Command { .. } => {} ++ PaletteItemContent::SmartIdeIntent { .. } => {} + PaletteItemContent::Workspace { .. } => {} + PaletteItemContent::RunAndDebug { .. } => {} + PaletteItemContent::SshHost { .. } => {} +diff --git a/lapce-app/src/palette/item.rs b/lapce-app/src/palette/item.rs +index 8f0cf04..f9e7833 100644 +--- a/lapce-app/src/palette/item.rs ++++ b/lapce-app/src/palette/item.rs +@@ -24,6 +24,9 @@ pub enum PaletteItemContent { + PaletteHelp { + cmd: LapceWorkbenchCommand, + }, ++ SmartIdeIntent { ++ intent: String, ++ }, + File { + path: PathBuf, + full_path: PathBuf, +diff --git a/lapce-app/src/palette/kind.rs b/lapce-app/src/palette/kind.rs +index 1a1b894..6e9e56f 100644 +--- a/lapce-app/src/palette/kind.rs ++++ b/lapce-app/src/palette/kind.rs +@@ -12,6 +12,7 @@ pub enum PaletteKind { + Reference, + DocumentSymbol, + WorkspaceSymbol, ++ SmartIdeIntent, + SshHost, + #[cfg(windows)] + WslHost, +@@ -34,6 +35,7 @@ pub fn symbol(&self) -> &'static str { + PaletteKind::Line => "/", + PaletteKind::DocumentSymbol => "@", + PaletteKind::WorkspaceSymbol => "#", ++ PaletteKind::SmartIdeIntent => "!", + // PaletteKind::GlobalSearch => "?", + PaletteKind::Workspace => ">", + PaletteKind::Command => ":", +@@ -61,6 +63,7 @@ pub fn from_input(input: &str) -> PaletteKind { + _ if input.starts_with('/') => PaletteKind::Line, + _ if input.starts_with('@') => PaletteKind::DocumentSymbol, + _ if input.starts_with('#') => PaletteKind::WorkspaceSymbol, ++ _ if input.starts_with('!') => PaletteKind::SmartIdeIntent, + _ if input.starts_with('>') => PaletteKind::Workspace, + _ if input.starts_with(':') => PaletteKind::Command, + _ if input.starts_with('<') => PaletteKind::TerminalProfile, +@@ -79,6 +82,9 @@ pub fn command(self) -> Option { + PaletteKind::WorkspaceSymbol => { + Some(LapceWorkbenchCommand::PaletteWorkspaceSymbol) + } ++ PaletteKind::SmartIdeIntent => { ++ Some(LapceWorkbenchCommand::PaletteSmartIdeIntent) ++ } + PaletteKind::Workspace => Some(LapceWorkbenchCommand::PaletteWorkspace), + PaletteKind::Command => Some(LapceWorkbenchCommand::PaletteCommand), + PaletteKind::File => Some(LapceWorkbenchCommand::Palette), +@@ -136,6 +142,7 @@ pub fn get_input<'a>(&self, input: &'a str) -> &'a str { + | PaletteKind::Workspace + | PaletteKind::DocumentSymbol + | PaletteKind::WorkspaceSymbol ++ | PaletteKind::SmartIdeIntent + | PaletteKind::Line + | PaletteKind::TerminalProfile + // | PaletteType::GlobalSearch +diff --git a/lapce-app/src/smart_ide.rs b/lapce-app/src/smart_ide.rs +new file mode 100644 +index 0000000..44f6478 +--- /dev/null ++++ b/lapce-app/src/smart_ide.rs +@@ -0,0 +1,154 @@ ++use std::time::Duration; ++ ++use anyhow::{Context, Result, anyhow}; ++use reqwest::blocking::Client; ++use serde_json::{Value, json}; ++ ++use crate::config::LapceConfig; ++ ++pub const CONFIG_SECTION: &str = "smart-ide"; ++ ++pub const KEY_ORCHESTRATOR_URL: &str = "orchestrator_url"; ++pub const KEY_ORCHESTRATOR_TOKEN: &str = "orchestrator_token"; ++ ++pub const KEY_IA_DEV_GATEWAY_URL: &str = "ia_dev_gateway_url"; ++pub const KEY_IA_DEV_GATEWAY_TOKEN: &str = "ia_dev_gateway_token"; ++ ++fn plugin_string(config: &LapceConfig, key: &str) -> Option { ++ config ++ .plugins ++ .get(CONFIG_SECTION) ++ .and_then(|m| m.get(key)) ++ .and_then(|v| v.as_str()) ++ .map(|s| s.to_string()) ++} ++ ++fn normalize_base_url(raw: &str) -> String { ++ raw.trim_end_matches('/').to_string() ++} ++ ++pub fn orchestrator_base_url(config: &LapceConfig) -> String { ++ plugin_string(config, KEY_ORCHESTRATOR_URL) ++ .map(|s| normalize_base_url(&s)) ++ .unwrap_or_else(|| "http://127.0.0.1:37145".to_string()) ++} ++ ++pub fn orchestrator_token(config: &LapceConfig) -> Result { ++ plugin_string(config, KEY_ORCHESTRATOR_TOKEN).ok_or_else(|| { ++ anyhow!( ++ "Missing setting: [smart-ide].{KEY_ORCHESTRATOR_TOKEN}\n\ ++\n\ ++Add it to your settings file:\n\ ++- global: settings.toml\n\ ++- per-workspace: .lapce/settings.toml\n\ ++\n\ ++Example:\n\ ++[smart-ide]\n\ ++orchestrator_url = \"http://127.0.0.1:37145\"\n\ ++orchestrator_token = \"...\"" ++ ) ++ }) ++} ++ ++pub fn ia_dev_gateway_base_url(config: &LapceConfig) -> String { ++ plugin_string(config, KEY_IA_DEV_GATEWAY_URL) ++ .map(|s| normalize_base_url(&s)) ++ .unwrap_or_else(|| "http://127.0.0.1:37144".to_string()) ++} ++ ++pub fn ia_dev_gateway_token(config: &LapceConfig) -> Result { ++ plugin_string(config, KEY_IA_DEV_GATEWAY_TOKEN).ok_or_else(|| { ++ anyhow!( ++ "Missing setting: [smart-ide].{KEY_IA_DEV_GATEWAY_TOKEN}\n\ ++\n\ ++Add it to your settings file.\n\ ++\n\ ++Example:\n\ ++[smart-ide]\n\ ++ia_dev_gateway_url = \"http://127.0.0.1:37144\"\n\ ++ia_dev_gateway_token = \"...\"" ++ ) ++ }) ++} ++ ++fn http_client() -> Result { ++ Client::builder() ++ .timeout(Duration::from_secs(20)) ++ .build() ++ .context("Failed to build HTTP client") ++} ++ ++pub fn orchestrator_execute(config: &LapceConfig, intent: &str) -> Result { ++ let base_url = orchestrator_base_url(config); ++ let token = orchestrator_token(config)?; ++ ++ let url = format!("{base_url}/v1/execute"); ++ let body = json!({ ++ "intent": intent, ++ }); ++ ++ let client = http_client()?; ++ let resp = client ++ .post(url) ++ .bearer_auth(token) ++ .json(&body) ++ .send() ++ .context("Orchestrator request failed")?; ++ ++ let status = resp.status(); ++ let text = resp.text().context("Failed to read orchestrator response body")?; ++ if !status.is_success() { ++ return Err(anyhow!( ++ "Orchestrator error (HTTP {status}):\n{text}" ++ )); ++ } ++ ++ serde_json::from_str(&text).context("Failed to parse orchestrator JSON response") ++} ++ ++pub fn orchestrator_timeline(config: &LapceConfig) -> Result { ++ let base_url = orchestrator_base_url(config); ++ let token = orchestrator_token(config)?; ++ ++ let url = format!("{base_url}/v1/timeline"); ++ let client = http_client()?; ++ let resp = client ++ .get(url) ++ .bearer_auth(token) ++ .send() ++ .context("Orchestrator request failed")?; ++ ++ let status = resp.status(); ++ let text = resp.text().context("Failed to read orchestrator response body")?; ++ if !status.is_success() { ++ return Err(anyhow!( ++ "Orchestrator error (HTTP {status}):\n{text}" ++ )); ++ } ++ ++ serde_json::from_str(&text).context("Failed to parse orchestrator JSON response") ++} ++ ++pub fn ia_dev_list_agents(config: &LapceConfig) -> Result { ++ let base_url = ia_dev_gateway_base_url(config); ++ let token = ia_dev_gateway_token(config)?; ++ ++ let url = format!("{base_url}/v1/agents"); ++ let client = http_client()?; ++ let resp = client ++ .get(url) ++ .bearer_auth(token) ++ .send() ++ .context("IA-dev-gateway request failed")?; ++ ++ let status = resp.status(); ++ let text = resp.text().context("Failed to read ia-dev-gateway response body")?; ++ if !status.is_success() { ++ return Err(anyhow!( ++ "IA-dev-gateway error (HTTP {status}):\n{text}" ++ )); ++ } ++ ++ serde_json::from_str(&text).context("Failed to parse ia-dev-gateway JSON response") ++} ++ +diff --git a/lapce-app/src/window_tab.rs b/lapce-app/src/window_tab.rs +index 41223a9..857a215 100644 +--- a/lapce-app/src/window_tab.rs ++++ b/lapce-app/src/window_tab.rs +@@ -713,6 +713,100 @@ pub fn run_lapce_command(&self, cmd: LapceCommand) { + } + } + ++ fn smart_ide_open_timeline(&self) { ++ let config = self.common.config.get_untracked(); ++ if let Err(err) = crate::smart_ide::orchestrator_token(&config) { ++ self.common.internal_command.send(InternalCommand::ShowAlert { ++ title: "Smart IDE".to_string(), ++ msg: err.to_string(), ++ buttons: Vec::new(), ++ }); ++ return; ++ } ++ ++ self.main_split.open_named_scratch( ++ "SmartIDE-timeline.json", ++ "Loading orchestrator timeline...\n".to_string(), ++ ); ++ ++ let main_split = self.main_split.clone(); ++ let send = create_ext_action( ++ self.common.scope, ++ move |result: std::result::Result| match result { ++ Ok(text) => { ++ main_split.open_named_scratch("SmartIDE-timeline.json", text); ++ } ++ Err(err) => { ++ main_split.open_named_scratch( ++ "SmartIDE-timeline.json", ++ format!("Error:\n{err}"), ++ ); ++ } ++ }, ++ ); ++ ++ std::thread::Builder::new() ++ .name("SmartIdeTimeline".to_owned()) ++ .spawn(move || { ++ let result = match crate::smart_ide::orchestrator_timeline(&config) { ++ Ok(v) => match serde_json::to_string_pretty(&v) { ++ Ok(s) => Ok(s), ++ Err(e) => Ok(format!("{v}\n\n(pretty print failed: {e})")), ++ }, ++ Err(e) => Err(e.to_string()), ++ }; ++ send(result); ++ }) ++ .ok(); ++ } ++ ++ fn smart_ide_list_agents(&self) { ++ let config = self.common.config.get_untracked(); ++ if let Err(err) = crate::smart_ide::ia_dev_gateway_token(&config) { ++ self.common.internal_command.send(InternalCommand::ShowAlert { ++ title: "Smart IDE".to_string(), ++ msg: err.to_string(), ++ buttons: Vec::new(), ++ }); ++ return; ++ } ++ ++ self.main_split.open_named_scratch( ++ "SmartIDE-agents.json", ++ "Loading ia-dev-gateway agents...\n".to_string(), ++ ); ++ ++ let main_split = self.main_split.clone(); ++ let send = create_ext_action( ++ self.common.scope, ++ move |result: std::result::Result| match result { ++ Ok(text) => { ++ main_split.open_named_scratch("SmartIDE-agents.json", text); ++ } ++ Err(err) => { ++ main_split.open_named_scratch( ++ "SmartIDE-agents.json", ++ format!("Error:\n{err}"), ++ ); ++ } ++ }, ++ ); ++ ++ std::thread::Builder::new() ++ .name("SmartIdeListAgents".to_owned()) ++ .spawn(move || { ++ let result = match crate::smart_ide::ia_dev_list_agents(&config) { ++ Ok(v) => match serde_json::to_string_pretty(&v) { ++ Ok(s) => Ok(s), ++ Err(e) => Ok(format!("{v}\n\n(pretty print failed: {e})")), ++ }, ++ Err(e) => Err(e.to_string()), ++ }; ++ send(result); ++ }) ++ .ok(); ++ } ++ + pub fn run_workbench_command( + &self, + cmd: LapceWorkbenchCommand, +@@ -1105,6 +1199,7 @@ pub fn run_workbench_command( + // ==== Palette Commands ==== + PaletteHelp => self.palette.run(PaletteKind::PaletteHelp), + PaletteHelpAndFile => self.palette.run(PaletteKind::HelpAndFile), ++ PaletteSmartIdeIntent => self.palette.run(PaletteKind::SmartIdeIntent), + PaletteLine => { + self.palette.run(PaletteKind::Line); + } +@@ -1141,6 +1236,10 @@ pub fn run_workbench_command( + } + DiffFiles => self.palette.run(PaletteKind::DiffFiles), + ++ // ==== Smart IDE ==== ++ SmartIdeTimeline => self.smart_ide_open_timeline(), ++ SmartIdeListAgents => self.smart_ide_list_agents(), ++ + // ==== Running / Debugging ==== + RunAndDebugRestart => { + let active_term = self.terminal.debug.active_term.get_untracked(); diff --git a/patches/lapce/0002-feat-add-Smart-IDE-cockpit-panel.patch b/patches/lapce/0002-feat-add-Smart-IDE-cockpit-panel.patch new file mode 100644 index 0000000..a7825c8 --- /dev/null +++ b/patches/lapce/0002-feat-add-Smart-IDE-cockpit-panel.patch @@ -0,0 +1,2278 @@ +From 43ee9102b485ed6cdc9d67131376d5738446c788 Mon Sep 17 00:00:00 2001 +From: Nicolas Cantu +Date: Mon, 6 Apr 2026 13:00:09 +0200 +Subject: [PATCH 2/3] feat: add Smart IDE cockpit panel + +- Add Smart IDE panel with tabs (Agents, Runs, Services, Connection) +- Add pinned actions bar and command palette entries for pinned actions +- Execute ia-dev-gateway runs and stream SSE logs in the Runs tab +- Add connection helpers (health checks and SSH tunnel plan + copy) + +diff --git a/lapce-app/src/command.rs b/lapce-app/src/command.rs +index 25f9027..f03d93c 100644 +--- a/lapce-app/src/command.rs ++++ b/lapce-app/src/command.rs +@@ -422,6 +422,46 @@ pub enum LapceWorkbenchCommand { + #[strum(serialize = "smart_ide.agents.list")] + SmartIdeListAgents, + ++ #[strum(message = "Smart IDE: Open Panel")] ++ #[strum(serialize = "smart_ide.open")] ++ SmartIdeOpenPanel, ++ ++ #[strum(message = "Smart IDE: Run Pinned Action 1")] ++ #[strum(serialize = "smart_ide.pinned_1")] ++ SmartIdePinnedAction1, ++ ++ #[strum(message = "Smart IDE: Run Pinned Action 2")] ++ #[strum(serialize = "smart_ide.pinned_2")] ++ SmartIdePinnedAction2, ++ ++ #[strum(message = "Smart IDE: Run Pinned Action 3")] ++ #[strum(serialize = "smart_ide.pinned_3")] ++ SmartIdePinnedAction3, ++ ++ #[strum(message = "Smart IDE: Run Pinned Action 4")] ++ #[strum(serialize = "smart_ide.pinned_4")] ++ SmartIdePinnedAction4, ++ ++ #[strum(message = "Smart IDE: Run Pinned Action 5")] ++ #[strum(serialize = "smart_ide.pinned_5")] ++ SmartIdePinnedAction5, ++ ++ #[strum(message = "Smart IDE: Run Pinned Action 6")] ++ #[strum(serialize = "smart_ide.pinned_6")] ++ SmartIdePinnedAction6, ++ ++ #[strum(message = "Smart IDE: Run Pinned Action 7")] ++ #[strum(serialize = "smart_ide.pinned_7")] ++ SmartIdePinnedAction7, ++ ++ #[strum(message = "Smart IDE: Run Pinned Action 8")] ++ #[strum(serialize = "smart_ide.pinned_8")] ++ SmartIdePinnedAction8, ++ ++ #[strum(message = "Smart IDE: Run Pinned Action 9")] ++ #[strum(serialize = "smart_ide.pinned_9")] ++ SmartIdePinnedAction9, ++ + #[strum(serialize = "source_control.checkout_reference")] + CheckoutReference, + +diff --git a/lapce-app/src/lib.rs b/lapce-app/src/lib.rs +index ea3c659..fad1792 100644 +--- a/lapce-app/src/lib.rs ++++ b/lapce-app/src/lib.rs +@@ -34,6 +34,7 @@ + pub mod snippet; + pub mod source_control; + pub mod smart_ide; ++pub mod smart_ide_ui; + pub mod status; + pub mod terminal; + pub mod text_area; +diff --git a/lapce-app/src/panel/data.rs b/lapce-app/src/panel/data.rs +index b18f06d..ea72f96 100644 +--- a/lapce-app/src/panel/data.rs ++++ b/lapce-app/src/panel/data.rs +@@ -26,6 +26,7 @@ pub fn default_panel_order() -> PanelOrder { + PanelPosition::LeftTop, + im::vector![ + PanelKind::FileExplorer, ++ PanelKind::SmartIde, + PanelKind::Plugin, + PanelKind::SourceControl, + PanelKind::Debug, +diff --git a/lapce-app/src/panel/kind.rs b/lapce-app/src/panel/kind.rs +index 695990e..e2477d4 100644 +--- a/lapce-app/src/panel/kind.rs ++++ b/lapce-app/src/panel/kind.rs +@@ -10,6 +10,7 @@ + pub enum PanelKind { + Terminal, + FileExplorer, ++ SmartIde, + SourceControl, + Plugin, + Search, +@@ -26,6 +27,7 @@ pub fn svg_name(&self) -> &'static str { + match &self { + PanelKind::Terminal => LapceIcons::TERMINAL, + PanelKind::FileExplorer => LapceIcons::FILE_EXPLORER, ++ PanelKind::SmartIde => LapceIcons::LIGHTBULB, + PanelKind::SourceControl => LapceIcons::SCM, + PanelKind::Plugin => LapceIcons::EXTENSIONS, + PanelKind::Search => LapceIcons::SEARCH, +@@ -52,6 +54,7 @@ pub fn default_position(&self) -> PanelPosition { + match self { + PanelKind::Terminal => PanelPosition::BottomLeft, + PanelKind::FileExplorer => PanelPosition::LeftTop, ++ PanelKind::SmartIde => PanelPosition::LeftTop, + PanelKind::SourceControl => PanelPosition::LeftTop, + PanelKind::Plugin => PanelPosition::LeftTop, + PanelKind::Search => PanelPosition::BottomLeft, +diff --git a/lapce-app/src/panel/mod.rs b/lapce-app/src/panel/mod.rs +index 016192e..6324e60 100644 +--- a/lapce-app/src/panel/mod.rs ++++ b/lapce-app/src/panel/mod.rs +@@ -10,6 +10,7 @@ + pub mod problem_view; + pub mod references_view; + pub mod source_control_view; ++pub mod smart_ide_view; + pub mod style; + pub mod terminal_view; + pub mod view; +diff --git a/lapce-app/src/panel/smart_ide_view.rs b/lapce-app/src/panel/smart_ide_view.rs +new file mode 100644 +index 0000000..65371aa +--- /dev/null ++++ b/lapce-app/src/panel/smart_ide_view.rs +@@ -0,0 +1,1025 @@ ++use std::{rc::Rc, sync::Arc}; ++ ++use floem::{ ++ IntoView, ++ View, ++ event::EventListener, ++ reactive::{ ++ ReadSignal, Scope, SignalGet, SignalUpdate, SignalWith, ++ create_memo, create_rw_signal, ++ }, ++ style::CursorStyle, ++ views::{ ++ Decorators, container, dyn_stack, h_stack, label, scroll, stack, ++ stack_from_iter, text, virtual_stack, ++ }, ++}; ++use floem::views::editor::text::SystemClipboard; ++use im::Vector; ++use lapce_core::register::Clipboard; ++ ++use super::{kind::PanelKind, position::PanelPosition}; ++use crate::{ ++ command::InternalCommand, ++ config::{LapceConfig, color::LapceColor}, ++ smart_ide_ui::{SmartIdeRunLogKind, SmartIdeTab}, ++ text_input::TextInputBuilder, ++ window_tab::{Focus, WindowTabData}, ++}; ++ ++#[derive(Clone, PartialEq, Eq)] ++struct SmartIdeAgent { ++ id: String, ++ name: String, ++ summary: String, ++ category: String, ++ runnable: bool, ++} ++ ++#[derive(Clone, PartialEq, Eq)] ++enum HealthStatus { ++ Unknown, ++ Ok, ++ Error(String), ++} ++ ++const RUNNABLE_AGENT_IDS: [&str; 5] = [ ++ "change-to-all-branches", ++ "branch-align-by-script-from-test", ++ "deploy-by-script", ++ "push-by-script", ++ "site-generate", ++]; ++ ++fn category_for_agent_id(id: &str) -> &'static str { ++ match id { ++ "site-generate" => "Web generation", ++ "deploy-by-script" | "deploy-pprod-or-prod" | "push-by-script" ++ | "branch-align-by-script-from-test" | "change-to-all-branches" ++ | "setup-host" => "DevOps", ++ "fix" | "evol" | "code" | "fix-search" => "Code", ++ "docupdate" | "fix-lint" | "closure-point-7-justification" => "Docs / Quality", ++ "git-issues-process" | "agent-loop" | "notary-ai-process" ++ | "notary-ai-loop" => "Ticketing / Notary", ++ _ => "Other", ++ } ++} ++ ++fn label_for_action(action: &str) -> String { ++ match action { ++ "site-generate" => "Generate site".to_string(), ++ "push-by-script" => "Push".to_string(), ++ "deploy-by-script:test" => "Deploy test".to_string(), ++ "deploy-by-script:pprod" => "Deploy pprod".to_string(), ++ "deploy-by-script:prod" => "Deploy prod".to_string(), ++ "branch-align-by-script-from-test:test" => "Align from test".to_string(), ++ "smart-ide:tunnel" => "SSH tunnel".to_string(), ++ _ => action.to_string(), ++ } ++} ++ ++fn action_is_runnable(action: &str) -> bool { ++ if action.starts_with("smart-ide:") { ++ return true; ++ } ++ let agent_id = action ++ .split_once(':') ++ .map(|(a, _)| a) ++ .unwrap_or(action) ++ .trim(); ++ if agent_id.is_empty() { ++ return false; ++ } ++ RUNNABLE_AGENT_IDS.contains(&agent_id) ++} ++ ++fn pill_button( ++ label_text: impl Fn() -> String + 'static, ++ on_click: impl Fn() + 'static, ++ active: impl Fn() -> bool + 'static, ++ config: ReadSignal>, ++) -> impl View { ++ label(label_text) ++ .on_click_stop(move |_| on_click()) ++ .style(move |s| { ++ let config = config.get(); ++ s.padding_horiz(10.0) ++ .padding_vert(6.0) ++ .border(1.0) ++ .border_radius(999.0) ++ .border_color(config.color(LapceColor::LAPCE_BORDER)) ++ .cursor(CursorStyle::Pointer) ++ .apply_if(active(), |s| { ++ s.background(config.color(LapceColor::PANEL_HOVERED_ACTIVE_BACKGROUND)) ++ }) ++ .hover(|s| s.background(config.color(LapceColor::PANEL_HOVERED_BACKGROUND))) ++ }) ++} ++ ++fn pill_action_button( ++ label_text: impl Fn() -> String + 'static, ++ on_click: impl Fn() + 'static, ++ disabled: impl Fn() -> bool + 'static + Copy, ++ config: ReadSignal>, ++) -> impl View { ++ label(label_text) ++ .on_click_stop(move |_| on_click()) ++ .disabled(disabled) ++ .style(move |s| { ++ let config = config.get(); ++ s.padding_horiz(10.0) ++ .padding_vert(6.0) ++ .border(1.0) ++ .border_radius(999.0) ++ .border_color(config.color(LapceColor::LAPCE_BORDER)) ++ .cursor(CursorStyle::Pointer) ++ .hover(|s| s.background(config.color(LapceColor::PANEL_HOVERED_BACKGROUND))) ++ .active(|s| { ++ s.background(config.color(LapceColor::PANEL_HOVERED_ACTIVE_BACKGROUND)) ++ }) ++ .disabled(|s| { ++ s.cursor(CursorStyle::Default) ++ .color(config.color(LapceColor::EDITOR_DIM)) ++ }) ++ }) ++} ++ ++pub fn smart_ide_panel( ++ window_tab_data: Rc, ++ _position: PanelPosition, ++) -> impl View { ++ let config = window_tab_data.common.config; ++ let focus = window_tab_data.common.focus; ++ let internal_command = window_tab_data.common.internal_command; ++ ++ let active_tab = window_tab_data.smart_ide.active_tab; ++ let agents = create_rw_signal(Vector::::new()); ++ let agents_loading = create_rw_signal(false); ++ let agents_error = create_rw_signal(None::); ++ ++ let health_orch = create_rw_signal(HealthStatus::Unknown); ++ let health_ia = create_rw_signal(HealthStatus::Unknown); ++ let health_tools = create_rw_signal(HealthStatus::Unknown); ++ let health_loading = create_rw_signal(false); ++ ++ let tunnel_cmd = create_rw_signal(String::new()); ++ let tunnel_hint = create_rw_signal(String::new()); ++ let tunnel_error = create_rw_signal(None::); ++ let tunnel_loading = create_rw_signal(false); ++ ++ let cx = Scope::current(); ++ let text_input_view = TextInputBuilder::new().build( ++ cx, ++ window_tab_data.main_split.editors, ++ window_tab_data.common.clone(), ++ ); ++ let search_doc = text_input_view.doc_signal(); ++ ++ let filtered_agents = create_memo(move |_| { ++ let pattern = search_doc ++ .get() ++ .buffer ++ .with(|b| b.to_string().trim().to_lowercase()); ++ if pattern.is_empty() { ++ return agents.get(); ++ } ++ agents ++ .get() ++ .into_iter() ++ .filter(|a| { ++ a.id.to_lowercase().contains(&pattern) ++ || a.name.to_lowercase().contains(&pattern) ++ || a.summary.to_lowercase().contains(&pattern) ++ || a.category.to_lowercase().contains(&pattern) ++ }) ++ .collect() ++ }); ++ ++ let refresh_agents: Rc = { ++ let config = config; ++ let agents = agents.write_only(); ++ let agents_loading = agents_loading; ++ let agents_error = agents_error; ++ let scope = window_tab_data.common.scope; ++ Rc::new(move || { ++ agents_loading.set(true); ++ agents_error.set(None); ++ let cfg = config.get_untracked(); ++ let send = floem::ext_event::create_ext_action( ++ scope, ++ move |result: std::result::Result, String>| { ++ agents_loading.set(false); ++ match result { ++ Ok(list) => { ++ agents.set(list); ++ agents_error.set(None); ++ } ++ Err(err) => { ++ agents_error.set(Some(err)); ++ } ++ } ++ }, ++ ); ++ std::thread::Builder::new() ++ .name("SmartIdeAgentsRefresh".to_owned()) ++ .spawn(move || { ++ let parsed = (|| { ++ let v = crate::smart_ide::ia_dev_list_agents(&cfg)?; ++ let arr = v ++ .get("agents") ++ .and_then(|v| v.as_array()) ++ .ok_or_else(|| { ++ anyhow::anyhow!( ++ "Invalid ia-dev-gateway response: missing 'agents' array" ++ ) ++ })?; ++ let mut out = Vector::new(); ++ for a in arr { ++ let Some(id) = a.get("id").and_then(|v| v.as_str()) else { ++ continue; ++ }; ++ let name = a ++ .get("name") ++ .and_then(|v| v.as_str()) ++ .unwrap_or(id) ++ .to_string(); ++ let summary = a ++ .get("description") ++ .and_then(|v| v.as_str()) ++ .or_else(|| a.get("summary").and_then(|v| v.as_str())) ++ .unwrap_or("") ++ .to_string(); ++ let category = category_for_agent_id(id).to_string(); ++ let runnable = RUNNABLE_AGENT_IDS.contains(&id); ++ out.push_back(SmartIdeAgent { ++ id: id.to_string(), ++ name, ++ summary, ++ category, ++ runnable, ++ }); ++ } ++ Ok::<_, anyhow::Error>(out) ++ })() ++ .map_err(|e| e.to_string()); ++ send(parsed); ++ }) ++ .ok(); ++ }) ++ }; ++ ++ // Reduce clicks: load the catalog on first open. ++ refresh_agents(); ++ ++ let refresh_health: Rc = { ++ let scope = window_tab_data.common.scope; ++ let config = config; ++ let health_orch = health_orch; ++ let health_ia = health_ia; ++ let health_tools = health_tools; ++ let health_loading = health_loading; ++ Rc::new(move || { ++ health_loading.set(true); ++ let cfg = config.get_untracked(); ++ let send = floem::ext_event::create_ext_action( ++ scope, ++ move |result: (HealthStatus, HealthStatus, HealthStatus)| { ++ health_orch.set(result.0); ++ health_ia.set(result.1); ++ health_tools.set(result.2); ++ health_loading.set(false); ++ }, ++ ); ++ std::thread::Builder::new() ++ .name("SmartIdeHealth".to_owned()) ++ .spawn(move || { ++ let orch = crate::smart_ide::orchestrator_health(&cfg) ++ .map(|_| HealthStatus::Ok) ++ .unwrap_or_else(|e| HealthStatus::Error(e.to_string())); ++ let ia = crate::smart_ide::ia_dev_gateway_health(&cfg) ++ .map(|_| HealthStatus::Ok) ++ .unwrap_or_else(|e| HealthStatus::Error(e.to_string())); ++ let tools = crate::smart_ide::tools_bridge_health(&cfg) ++ .map(|_| HealthStatus::Ok) ++ .unwrap_or_else(|e| HealthStatus::Error(e.to_string())); ++ send((orch, ia, tools)); ++ }) ++ .ok(); ++ }) ++ }; ++ ++ let refresh_tunnel_plan: Rc = { ++ let scope = window_tab_data.common.scope; ++ let config = config; ++ let workspace_root = window_tab_data.workspace.path.clone(); ++ let tunnel_cmd = tunnel_cmd; ++ let tunnel_hint = tunnel_hint; ++ let tunnel_error = tunnel_error; ++ let tunnel_loading = tunnel_loading; ++ Rc::new(move || { ++ tunnel_loading.set(true); ++ tunnel_error.set(None); ++ let cfg = config.get_untracked(); ++ let ws = workspace_root.clone(); ++ let send = floem::ext_event::create_ext_action( ++ scope, ++ move |result: std::result::Result<(String, String), String>| { ++ tunnel_loading.set(false); ++ match result { ++ Ok((cmd, hint)) => { ++ tunnel_cmd.set(cmd); ++ tunnel_hint.set(hint); ++ tunnel_error.set(None); ++ } ++ Err(err) => { ++ tunnel_error.set(Some(err)); ++ } ++ } ++ }, ++ ); ++ std::thread::Builder::new() ++ .name("SmartIdeTunnelPlan".to_owned()) ++ .spawn(move || { ++ let res = (|| { ++ let plan = crate::smart_ide::ssh_tunnel_plan( ++ &cfg, ++ ws.as_deref(), ++ "minimal", ++ )?; ++ let cmd = crate::smart_ide::shell_command_from_argv(&plan.argv); ++ Ok::<_, anyhow::Error>((cmd, plan.hint)) ++ })() ++ .map_err(|e| e.to_string()); ++ send(res); ++ }) ++ .ok(); ++ }) ++ }; ++ ++ { ++ let active_tab = active_tab.read_only(); ++ let refresh_health = refresh_health.clone(); ++ let refresh_tunnel_plan = refresh_tunnel_plan.clone(); ++ let did_auto = create_rw_signal(false); ++ cx.create_effect(move |_| { ++ if active_tab.get() == SmartIdeTab::Connection { ++ if !did_auto.get_untracked() { ++ refresh_health(); ++ refresh_tunnel_plan(); ++ did_auto.set(true); ++ } ++ } ++ }); ++ } ++ ++ let open_agent_definition: Rc = { ++ let internal_command = internal_command; ++ let workspace = window_tab_data.workspace.clone(); ++ Rc::new(move |agent_id: String| { ++ let root = workspace.path.clone(); ++ let Some(root) = root else { ++ internal_command.send(InternalCommand::ShowAlert { ++ title: "Smart IDE".to_string(), ++ msg: "Workspace folder is required to open agent definitions.".to_string(), ++ buttons: Vec::new(), ++ }); ++ return; ++ }; ++ let path = root ++ .join("services") ++ .join("ia_dev") ++ .join(".smartIde") ++ .join("agents") ++ .join(format!("{agent_id}.md")); ++ internal_command.send(InternalCommand::OpenFile { path }); ++ }) ++ }; ++ ++ let header = h_stack(( ++ text("Smart IDE").style(|s| s.font_bold()), ++ label(move || { ++ if agents_loading.get() { ++ "Loading agents…".to_string() ++ } else { ++ "".to_string() ++ } ++ }) ++ .style(move |s| { ++ s.margin_left(10.0) ++ .color(config.get().color(LapceColor::EDITOR_DIM)) ++ }), ++ label(move || { ++ agents_error ++ .get() ++ .unwrap_or_default() ++ .lines() ++ .next() ++ .unwrap_or("") ++ .to_string() ++ }) ++ .style(move |s| { ++ s.margin_left(10.0) ++ .color(config.get().color(LapceColor::LAPCE_ERROR)) ++ }), ++ )) ++ .style(|s| s.items_center().width_pct(100.0).padding(10.0)); ++ ++ let pinned_actions = crate::smart_ide::pinned_actions(&config.get_untracked()); ++ let pinned_bar = container( ++ stack_from_iter(pinned_actions.into_iter().map({ ++ let window_tab_data = window_tab_data.clone(); ++ move |action| { ++ let label = label_for_action(&action); ++ let runnable = action_is_runnable(&action); ++ let wtd = window_tab_data.clone(); ++ pill_action_button( ++ move || label.clone(), ++ move || { ++ wtd.smart_ide_execute_action(action.clone()); ++ }, ++ move || !runnable, ++ config, ++ ) ++ .style(|s| s.margin_right(6.0)) ++ .into_any() ++ } ++ })), ++ ) ++ .style(|s| s.width_pct(100.0).padding_horiz(10.0).padding_bottom(6.0)) ++ .debug_name("Smart IDE Pinned Actions"); ++ ++ let default_env = ++ crate::smart_ide::resolve_env(&config.get_untracked(), window_tab_data.workspace.path.as_deref()); ++ ++ let tabs = h_stack(( ++ pill_button( ++ || "Agents".to_string(), ++ { ++ let active_tab = active_tab; ++ move || active_tab.set(SmartIdeTab::Agents) ++ }, ++ { ++ let active_tab = active_tab.read_only(); ++ move || active_tab.get() == SmartIdeTab::Agents ++ }, ++ config, ++ ), ++ pill_button( ++ || "Runs".to_string(), ++ { ++ let active_tab = active_tab; ++ move || active_tab.set(SmartIdeTab::Runs) ++ }, ++ { ++ let active_tab = active_tab.read_only(); ++ move || active_tab.get() == SmartIdeTab::Runs ++ }, ++ config, ++ ) ++ .style(|s| s.margin_left(6.0)), ++ pill_button( ++ || "Services".to_string(), ++ { ++ let active_tab = active_tab; ++ move || active_tab.set(SmartIdeTab::Services) ++ }, ++ { ++ let active_tab = active_tab.read_only(); ++ move || active_tab.get() == SmartIdeTab::Services ++ }, ++ config, ++ ) ++ .style(|s| s.margin_left(6.0)), ++ pill_button( ++ || "Connection".to_string(), ++ { ++ let active_tab = active_tab; ++ move || active_tab.set(SmartIdeTab::Connection) ++ }, ++ { ++ let active_tab = active_tab.read_only(); ++ move || active_tab.get() == SmartIdeTab::Connection ++ }, ++ config, ++ ) ++ .style(|s| s.margin_left(6.0)), ++ )) ++ .style(|s| s.items_center().width_pct(100.0).padding_horiz(10.0)); ++ ++ let window_tab_data_for_agents = window_tab_data.clone(); ++ let agents_tab = stack(( ++ container( ++ h_stack(( ++ text_input_view.style(|s| s.width_pct(100.0)), ++ pill_button( ++ || "Refresh".to_string(), ++ { ++ let refresh_agents = refresh_agents.clone(); ++ move || refresh_agents() ++ }, ++ || false, ++ config, ++ ) ++ .style(|s| s.margin_left(6.0)), ++ )) ++ .style(move |s| { ++ s.width_pct(100.0) ++ .padding_right(6.0) ++ .items_center() ++ .border(1.0) ++ .border_radius(6.0) ++ .border_color(config.get().color(LapceColor::LAPCE_BORDER)) ++ }), ++ ) ++ .style(|s| s.width_pct(100.0).padding(10.0)), ++ container(scroll(virtual_stack( ++ move || filtered_agents.get(), ++ |a| a.id.clone(), ++ move |agent| { ++ let agent_id_label = agent.id.clone(); ++ let agent_id_open = agent.id.clone(); ++ let agent_id_run = agent.id.clone(); ++ let desc = agent.summary.clone(); ++ let category = agent.category.clone(); ++ let runnable = agent.runnable; ++ stack(( ++ h_stack(( ++ stack(( ++ label(move || agent_id_label.clone()) ++ .style(|s| s.font_bold().margin_right(8.0)), ++ label(move || category.clone()).style(move |s| { ++ s.color(config.get().color(LapceColor::EDITOR_DIM)) ++ }), ++ )) ++ .style(|s| s.flex_col().min_width(0.0).flex_grow(1.0)), ++ label(move || { ++ if runnable { ++ "runnable".to_string() ++ } else { ++ "guided".to_string() ++ } ++ }) ++ .style(move |s| { ++ s.margin_left(8.0) ++ .padding_horiz(8.0) ++ .padding_vert(4.0) ++ .border(1.0) ++ .border_radius(999.0) ++ .border_color( ++ config.get().color(LapceColor::LAPCE_BORDER), ++ ) ++ .color(config.get().color(LapceColor::EDITOR_DIM)) ++ }), ++ pill_action_button( ++ || "Run".to_string(), ++ { ++ let wtd = window_tab_data_for_agents.clone(); ++ let action = if agent_id_run == "deploy-by-script" { ++ format!("deploy-by-script:{default_env}") ++ } else if agent_id_run == "branch-align-by-script-from-test" { ++ format!("branch-align-by-script-from-test:{default_env}") ++ } else { ++ agent_id_run.clone() ++ }; ++ move || { ++ wtd.smart_ide_execute_action(action.clone()); ++ } ++ }, ++ move || !runnable, ++ config, ++ ) ++ .style(|s| s.margin_left(8.0)), ++ pill_button( ++ || "Open".to_string(), ++ { ++ let open_agent_definition = open_agent_definition.clone(); ++ let agent_id_open = agent_id_open.clone(); ++ move || open_agent_definition(agent_id_open.clone()) ++ }, ++ || false, ++ config, ++ ) ++ .style(|s| s.margin_left(8.0)), ++ )) ++ .style(|s| s.items_center().width_pct(100.0)), ++ label(move || desc.clone()).style(move |s| { ++ s.margin_top(4.0) ++ .color(config.get().color(LapceColor::EDITOR_DIM)) ++ .min_width(0.0) ++ .text_ellipsis() ++ }), ++ )) ++ .style(move |s| { ++ s.width_pct(100.0) ++ .padding(10.0) ++ .border_bottom(1.0) ++ .border_color(config.get().color(LapceColor::LAPCE_BORDER)) ++ .hover(|s| { ++ s.cursor(CursorStyle::Pointer).background( ++ config.get().color(LapceColor::PANEL_HOVERED_BACKGROUND), ++ ) ++ }) ++ }) ++ .on_event_cont(EventListener::PointerDown, { ++ let focus = focus; ++ move |_| { ++ focus.set(Focus::Panel(PanelKind::SmartIde)); ++ } ++ }) ++ }, ++ ))) ++ .style(|s| s.size_pct(100.0, 100.0)), ++ )) ++ .style(|s| s.size_pct(100.0, 100.0).flex_col()) ++ .debug_name("Smart IDE Agents Tab"); ++ ++ let active_tab = active_tab.read_only(); ++ ++ let runs = window_tab_data.smart_ide.runs; ++ let selected_run_id = window_tab_data.smart_ide.selected_run_id; ++ let runs_tab = stack(( ++ container(text("Runs").style(|s| s.font_bold())) ++ .style(|s| s.width_pct(100.0).padding(10.0)), ++ container(scroll(dyn_stack( ++ move || runs.get(), ++ |r| r.run_id.clone(), ++ move |r| { ++ let run_id_for_cmp = r.run_id.clone(); ++ let run_id_label = r.run_id.clone(); ++ let run_id_click = r.run_id.clone(); ++ let agent_id = r.agent_id.clone(); ++ let status = r.status.clone(); ++ let is_selected = { ++ let selected_run_id = selected_run_id.read_only(); ++ let run_id_for_cmp = run_id_for_cmp.clone(); ++ move || { ++ selected_run_id.get().as_deref() == Some(run_id_for_cmp.as_str()) ++ } ++ }; ++ let selected_run_id = selected_run_id; ++ stack(( ++ label(move || format!("{agent_id}")).style(|s| s.font_bold()), ++ label(move || format!("{status}")).style(move |s| { ++ s.margin_left(10.0) ++ .color(config.get().color(LapceColor::EDITOR_DIM)) ++ }), ++ label(move || format!("{run_id_label}")).style(move |s| { ++ s.margin_left(10.0) ++ .color(config.get().color(LapceColor::EDITOR_DIM)) ++ .min_width(0.0) ++ .text_ellipsis() ++ }), ++ )) ++ .on_click_stop(move |_| { ++ selected_run_id.set(Some(run_id_click.clone())); ++ }) ++ .style(move |s| { ++ s.width_pct(100.0) ++ .padding(10.0) ++ .border_bottom(1.0) ++ .border_color(config.get().color(LapceColor::LAPCE_BORDER)) ++ .apply_if(is_selected(), |s| { ++ s.background(config.get().color( ++ LapceColor::PANEL_HOVERED_ACTIVE_BACKGROUND, ++ )) ++ }) ++ .hover(|s| { ++ s.cursor(CursorStyle::Pointer).background( ++ config.get().color(LapceColor::PANEL_HOVERED_BACKGROUND), ++ ) ++ }) ++ }) ++ }, ++ ))) ++ .style(|s| s.size_pct(100.0, 40.0)), ++ container( ++ scroll(label(move || { ++ let runs = runs.get(); ++ let Some(sel) = selected_run_id.get() else { ++ return "Select a run to see its logs.".to_string(); ++ }; ++ let Some(run) = runs.iter().find(|r| r.run_id == sel) else { ++ return "Selected run not found.".to_string(); ++ }; ++ let mut out = String::new(); ++ for line in &run.logs { ++ match line.kind { ++ SmartIdeRunLogKind::Stdout => out.push_str("[out] "), ++ SmartIdeRunLogKind::Stderr => out.push_str("[err] "), ++ SmartIdeRunLogKind::Event => {} ++ } ++ out.push_str(&line.text); ++ out.push('\n'); ++ } ++ out ++ })) ++ .style(|s| s.padding(10.0).width_pct(100.0)), ++ ) ++ .style(|s| s.size_pct(100.0, 60.0)), ++ )) ++ .style(|s| s.size_pct(100.0, 100.0).flex_col()) ++ .debug_name("Smart IDE Runs Tab"); ++ ++ let workspace_root = window_tab_data.workspace.path.clone(); ++ let cfg = config.get_untracked(); ++ let env = crate::smart_ide::resolve_env(&cfg, workspace_root.as_deref()); ++ let project_id = crate::smart_ide::resolve_project_id(&cfg, workspace_root.as_deref()) ++ .unwrap_or_else(|_| "".to_string()); ++ let preview_url = crate::smart_ide::resolve_preview_url(&cfg, workspace_root.as_deref(), &env) ++ .ok() ++ .flatten() ++ .unwrap_or_default(); ++ let has_preview_url = !preview_url.is_empty(); ++ let anythingllm_url = "http://127.0.0.1:3001/".to_string(); ++ let local_office_url = "http://127.0.0.1:8000/".to_string(); ++ ++ let open_web: Rc = { ++ let internal_command = internal_command; ++ Rc::new(move |url: String| { ++ internal_command.send(InternalCommand::OpenWebUri { uri: url }); ++ }) ++ }; ++ ++ let env_label = env.clone(); ++ let project_id_label = project_id.clone(); ++ let env_for_preview_label = env.clone(); ++ let project_id_for_preview_label = project_id.clone(); ++ ++ let services_tab = stack(( ++ container(text("Services").style(|s| s.font_bold())) ++ .style(|s| s.width_pct(100.0).padding(10.0)), ++ container(label(move || { ++ format!("project={project_id_label} env={env_label}") ++ })) ++ .style(move |s| { ++ s.width_pct(100.0) ++ .padding_horiz(10.0) ++ .padding_bottom(10.0) ++ .color(config.get().color(LapceColor::EDITOR_DIM)) ++ }), ++ container( ++ stack(( ++ text("Preview").style(|s| s.font_bold()), ++ label(move || { ++ if has_preview_url { ++ format!( ++ "Open the {env_for_preview_label} preview URL (from projects//conf.json)." ++ ) ++ } else { ++ format!( ++ "Missing preview URL: projects/{project_id_for_preview_label}/conf.json -> smart_ide.preview_urls.{env_for_preview_label}" ++ ) ++ } ++ }) ++ .style(move |s| s.margin_top(6.0).color(config.get().color(LapceColor::EDITOR_DIM))), ++ pill_action_button( ++ || "Open preview".to_string(), ++ { ++ let open_web = open_web.clone(); ++ let url = preview_url.clone(); ++ move || open_web(url.clone()) ++ }, ++ move || !has_preview_url, ++ config, ++ ) ++ .style(|s| s.margin_top(10.0)), ++ )) ++ .style(|s| s.flex_col()), ++ ) ++ .style(|s| s.width_pct(100.0).padding(10.0)), ++ container( ++ stack(( ++ text("AnythingLLM").style(|s| s.font_bold()), ++ label(|| "Opens the AnythingLLM web UI (requires tunnel mode=all).".to_string()) ++ .style(move |s| s.margin_top(6.0).color(config.get().color(LapceColor::EDITOR_DIM))), ++ pill_action_button( ++ || "Open AnythingLLM".to_string(), ++ { ++ let open_web = open_web.clone(); ++ let url = anythingllm_url.clone(); ++ move || open_web(url.clone()) ++ }, ++ || false, ++ config, ++ ) ++ .style(|s| s.margin_top(10.0)), ++ )) ++ .style(|s| s.flex_col()), ++ ) ++ .style(|s| s.width_pct(100.0).padding(10.0)), ++ container( ++ stack(( ++ text("Local Office").style(|s| s.font_bold()), ++ label(|| "Opens the local-office HTTP service (requires tunnel mode=all).".to_string()) ++ .style(move |s| s.margin_top(6.0).color(config.get().color(LapceColor::EDITOR_DIM))), ++ pill_action_button( ++ || "Open local-office".to_string(), ++ { ++ let open_web = open_web.clone(); ++ let url = local_office_url.clone(); ++ move || open_web(url.clone()) ++ }, ++ || false, ++ config, ++ ) ++ .style(|s| s.margin_top(10.0)), ++ )) ++ .style(|s| s.flex_col()), ++ ) ++ .style(|s| s.width_pct(100.0).padding(10.0)), ++ )) ++ .style(|s| s.size_pct(100.0, 100.0).flex_col()) ++ .debug_name("Smart IDE Services Tab"); ++ let orch_base = crate::smart_ide::orchestrator_base_url(&config.get_untracked()); ++ let ia_base = crate::smart_ide::ia_dev_gateway_base_url(&config.get_untracked()); ++ let tools_base = crate::smart_ide::tools_bridge_base_url(&config.get_untracked()); ++ ++ let connection_tab = stack(( ++ container(text("Connection").style(|s| s.font_bold())) ++ .style(|s| s.width_pct(100.0).padding(10.0)), ++ container(h_stack(( ++ pill_action_button( ++ || "Refresh health".to_string(), ++ { ++ let refresh_health = refresh_health.clone(); ++ move || refresh_health() ++ }, ++ move || health_loading.get(), ++ config, ++ ), ++ pill_action_button( ++ || "Refresh tunnel plan".to_string(), ++ { ++ let refresh_tunnel_plan = refresh_tunnel_plan.clone(); ++ move || refresh_tunnel_plan() ++ }, ++ move || tunnel_loading.get(), ++ config, ++ ) ++ .style(|s| s.margin_left(8.0)), ++ ))) ++ .style(|s| s.width_pct(100.0).padding_horiz(10.0).padding_bottom(10.0)), ++ container( ++ stack(( ++ text("Health").style(|s| s.font_bold()), ++ label(move || format!("orchestrator: {orch_base}")).style(move |s| { ++ s.margin_top(6.0) ++ .color(config.get().color(LapceColor::EDITOR_DIM)) ++ }), ++ label(move || { ++ match health_orch.get() { ++ HealthStatus::Unknown => "orchestrator status: unknown".to_string(), ++ HealthStatus::Ok => "orchestrator status: ok".to_string(), ++ HealthStatus::Error(e) => { ++ let first = e.lines().next().unwrap_or("error"); ++ format!("orchestrator status: error ({first})") ++ } ++ } ++ }) ++ .style(move |s| { ++ s.margin_top(6.0).apply_if( ++ matches!(health_orch.get(), HealthStatus::Error(_)), ++ |s| s.color(config.get().color(LapceColor::LAPCE_ERROR)), ++ ) ++ }), ++ label(move || format!("ia-dev-gateway: {ia_base}")).style(move |s| { ++ s.margin_top(10.0) ++ .color(config.get().color(LapceColor::EDITOR_DIM)) ++ }), ++ label(move || { ++ match health_ia.get() { ++ HealthStatus::Unknown => "ia-dev-gateway status: unknown".to_string(), ++ HealthStatus::Ok => "ia-dev-gateway status: ok".to_string(), ++ HealthStatus::Error(e) => { ++ let first = e.lines().next().unwrap_or("error"); ++ format!("ia-dev-gateway status: error ({first})") ++ } ++ } ++ }) ++ .style(move |s| { ++ s.margin_top(6.0).apply_if( ++ matches!(health_ia.get(), HealthStatus::Error(_)), ++ |s| s.color(config.get().color(LapceColor::LAPCE_ERROR)), ++ ) ++ }), ++ label(move || format!("tools-bridge: {tools_base}")).style(move |s| { ++ s.margin_top(10.0) ++ .color(config.get().color(LapceColor::EDITOR_DIM)) ++ }), ++ label(move || { ++ match health_tools.get() { ++ HealthStatus::Unknown => "tools-bridge status: unknown".to_string(), ++ HealthStatus::Ok => "tools-bridge status: ok".to_string(), ++ HealthStatus::Error(e) => { ++ let first = e.lines().next().unwrap_or("error"); ++ format!("tools-bridge status: error ({first})") ++ } ++ } ++ }) ++ .style(move |s| { ++ s.margin_top(6.0).apply_if( ++ matches!(health_tools.get(), HealthStatus::Error(_)), ++ |s| s.color(config.get().color(LapceColor::LAPCE_ERROR)), ++ ) ++ }), ++ label(move || { ++ if health_loading.get() { ++ "Health check running…".to_string() ++ } else { ++ "".to_string() ++ } ++ }) ++ .style(move |s| { ++ s.margin_top(10.0) ++ .color(config.get().color(LapceColor::EDITOR_DIM)) ++ }), ++ )) ++ .style(|s| s.flex_col()), ++ ) ++ .style(|s| s.width_pct(100.0).padding(10.0)), ++ container( ++ stack(( ++ text("SSH tunnel").style(|s| s.font_bold()), ++ label(move || { ++ tunnel_error ++ .get() ++ .unwrap_or_default() ++ .lines() ++ .next() ++ .unwrap_or("") ++ .to_string() ++ }) ++ .style(move |s| { ++ s.margin_top(6.0) ++ .color(config.get().color(LapceColor::LAPCE_ERROR)) ++ .apply_if(tunnel_error.get().is_none(), |s| s.hide()) ++ }), ++ label(move || tunnel_cmd.get()).style(move |s| { ++ s.margin_top(6.0) ++ .padding(8.0) ++ .border(1.0) ++ .border_radius(6.0) ++ .border_color(config.get().color(LapceColor::LAPCE_BORDER)) ++ .background(config.get().color(LapceColor::PANEL_BACKGROUND)) ++ }), ++ pill_action_button( ++ || "Copy command".to_string(), ++ { ++ let tunnel_cmd = tunnel_cmd; ++ move || { ++ let cmd = tunnel_cmd.get(); ++ if cmd.is_empty() { ++ return; ++ } ++ let mut clipboard = SystemClipboard::new(); ++ clipboard.put_string(&cmd); ++ } ++ }, ++ move || tunnel_cmd.get().is_empty(), ++ config, ++ ) ++ .style(|s| s.margin_top(10.0)), ++ label(move || tunnel_hint.get()).style(move |s| { ++ s.margin_top(10.0) ++ .color(config.get().color(LapceColor::EDITOR_DIM)) ++ }), ++ label(move || { ++ if tunnel_loading.get() { ++ "Generating tunnel plan…".to_string() ++ } else { ++ "".to_string() ++ } ++ }) ++ .style(move |s| { ++ s.margin_top(10.0) ++ .color(config.get().color(LapceColor::EDITOR_DIM)) ++ }), ++ )) ++ .style(|s| s.flex_col()), ++ ) ++ .style(|s| s.width_pct(100.0).padding(10.0)), ++ )) ++ .style(|s| s.size_pct(100.0, 100.0).flex_col()) ++ .debug_name("Smart IDE Connection Tab"); ++ ++ let content = stack(( ++ agents_tab.style(move |s| { ++ s.apply_if(active_tab.get() != SmartIdeTab::Agents, |s| s.hide()) ++ }), ++ runs_tab.style(move |s| { ++ s.apply_if(active_tab.get() != SmartIdeTab::Runs, |s| s.hide()) ++ }), ++ services_tab.style(move |s| { ++ s.apply_if(active_tab.get() != SmartIdeTab::Services, |s| s.hide()) ++ }), ++ connection_tab.style(move |s| { ++ s.apply_if(active_tab.get() != SmartIdeTab::Connection, |s| s.hide()) ++ }), ++ )) ++ .style(|s| s.size_pct(100.0, 100.0)); ++ ++ stack((header, pinned_bar, tabs, content)) ++ .style(|s| s.absolute().size_pct(100.0, 100.0).flex_col()) ++ .debug_name("Smart IDE Panel") ++} ++ +diff --git a/lapce-app/src/panel/view.rs b/lapce-app/src/panel/view.rs +index d9a9b0d..52929f5 100644 +--- a/lapce-app/src/panel/view.rs ++++ b/lapce-app/src/panel/view.rs +@@ -24,6 +24,7 @@ + position::{PanelContainerPosition, PanelPosition}, + problem_view::problem_panel, + source_control_view::source_control_panel, ++ smart_ide_view::smart_ide_panel, + terminal_view::terminal_panel, + }; + use crate::{ +@@ -477,6 +478,9 @@ fn panel_view( + PanelKind::FileExplorer => { + file_explorer_panel(window_tab_data.clone(), position).into_any() + } ++ PanelKind::SmartIde => { ++ smart_ide_panel(window_tab_data.clone(), position).into_any() ++ } + PanelKind::SourceControl => { + source_control_panel(window_tab_data.clone(), position) + .into_any() +@@ -554,6 +558,7 @@ fn panel_picker( + let tooltip = match p { + PanelKind::Terminal => "Terminal", + PanelKind::FileExplorer => "File Explorer", ++ PanelKind::SmartIde => "Smart IDE", + PanelKind::SourceControl => "Source Control", + PanelKind::Plugin => "Plugins", + PanelKind::Search => "Search", +diff --git a/lapce-app/src/smart_ide.rs b/lapce-app/src/smart_ide.rs +index 44f6478..51173b3 100644 +--- a/lapce-app/src/smart_ide.rs ++++ b/lapce-app/src/smart_ide.rs +@@ -1,7 +1,14 @@ + use std::time::Duration; ++use std::{ ++ fs, ++ io::{BufRead, BufReader}, ++ path::{Path, PathBuf}, ++ process::Command, ++}; + + use anyhow::{Context, Result, anyhow}; + use reqwest::blocking::Client; ++use serde::Deserialize; + use serde_json::{Value, json}; + + use crate::config::LapceConfig; +@@ -14,6 +21,14 @@ + pub const KEY_IA_DEV_GATEWAY_URL: &str = "ia_dev_gateway_url"; + pub const KEY_IA_DEV_GATEWAY_TOKEN: &str = "ia_dev_gateway_token"; + ++pub const KEY_TOOLS_BRIDGE_URL: &str = "tools_bridge_url"; ++pub const KEY_TOOLS_BRIDGE_TOKEN: &str = "tools_bridge_token"; ++ ++pub const KEY_PROJECT_ID: &str = "project_id"; ++pub const KEY_ENV: &str = "env"; ++ ++pub const KEY_PINNED_ACTIONS: &str = "pinned_actions"; ++ + fn plugin_string(config: &LapceConfig, key: &str) -> Option { + config + .plugins +@@ -27,6 +42,54 @@ fn normalize_base_url(raw: &str) -> String { + raw.trim_end_matches('/').to_string() + } + ++fn plugin_string_csv(config: &LapceConfig, key: &str) -> Vec { ++ let raw = plugin_string(config, key).unwrap_or_default(); ++ raw.split(',') ++ .map(|s| s.trim()) ++ .filter(|s| !s.is_empty()) ++ .map(|s| s.to_string()) ++ .collect() ++} ++ ++fn dir_exists(p: &Path) -> bool { ++ fs::metadata(p).map(|m| m.is_dir()).unwrap_or(false) ++} ++ ++fn is_smart_ide_repo_root(p: &Path) -> bool { ++ dir_exists(&p.join("projects")) ++ && dir_exists(&p.join("services")) ++ && dir_exists(&p.join("scripts")) ++} ++ ++fn find_smart_ide_repo_root(start: &Path) -> Option { ++ let mut cur: Option<&Path> = Some(start); ++ for _ in 0..10 { ++ let Some(p) = cur else { ++ break; ++ }; ++ if is_smart_ide_repo_root(p) { ++ return Some(p.to_path_buf()); ++ } ++ cur = p.parent(); ++ } ++ None ++} ++ ++fn resolve_repo_root(workspace_root: Option<&Path>) -> Result { ++ let Some(ws) = workspace_root else { ++ return Err(anyhow!( ++ "Workspace folder is required (open the smart_ide repo root)." ++ )); ++ }; ++ find_smart_ide_repo_root(ws).ok_or_else(|| { ++ anyhow!( ++ "Could not locate smart_ide repo root from workspace path: {ws:?}\n\ ++\n\ ++Open the smart_ide repo root so 'projects/', 'services/' and 'scripts/' are available." ++ ) ++ }) ++} ++ + pub fn orchestrator_base_url(config: &LapceConfig) -> String { + plugin_string(config, KEY_ORCHESTRATOR_URL) + .map(|s| normalize_base_url(&s)) +@@ -71,6 +134,135 @@ pub fn ia_dev_gateway_token(config: &LapceConfig) -> Result { + }) + } + ++pub fn tools_bridge_base_url(config: &LapceConfig) -> String { ++ plugin_string(config, KEY_TOOLS_BRIDGE_URL) ++ .map(|s| normalize_base_url(&s)) ++ .unwrap_or_else(|| "http://127.0.0.1:37147".to_string()) ++} ++ ++pub fn tools_bridge_token(config: &LapceConfig) -> Result { ++ plugin_string(config, KEY_TOOLS_BRIDGE_TOKEN).ok_or_else(|| { ++ anyhow!( ++ "Missing setting: [smart-ide].{KEY_TOOLS_BRIDGE_TOKEN}\n\ ++\n\ ++Add it to your settings file.\n\ ++\n\ ++Example:\n\ ++[smart-ide]\n\ ++tools_bridge_url = \"http://127.0.0.1:37147\"\n\ ++tools_bridge_token = \"...\"" ++ ) ++ }) ++} ++ ++pub fn pinned_actions(config: &LapceConfig) -> Vec { ++ let actions = plugin_string_csv(config, KEY_PINNED_ACTIONS); ++ if !actions.is_empty() { ++ return actions; ++ } ++ vec![ ++ "site-generate".to_string(), ++ "push-by-script".to_string(), ++ "deploy-by-script:test".to_string(), ++ "branch-align-by-script-from-test:test".to_string(), ++ "smart-ide:tunnel".to_string(), ++ ] ++} ++ ++fn read_active_project_file(repo_root: &Path) -> Result { ++ let p = repo_root.join("projects").join("active-project.json"); ++ let text = ++ fs::read_to_string(&p).with_context(|| format!("Failed to read {p:?}"))?; ++ serde_json::from_str(&text) ++ .with_context(|| format!("Failed to parse JSON in {p:?}")) ++} ++ ++pub fn resolve_project_id( ++ config: &LapceConfig, ++ workspace_root: Option<&Path>, ++) -> Result { ++ if let Some(id) = plugin_string(config, KEY_PROJECT_ID) { ++ if !id.trim().is_empty() { ++ return Ok(id.trim().to_string()); ++ } ++ } ++ if let Some(root) = workspace_root { ++ let repo_root = find_smart_ide_repo_root(root).unwrap_or_else(|| root.to_path_buf()); ++ if let Ok(v) = read_active_project_file(&repo_root) { ++ if let Some(id) = v.get("id").and_then(|v| v.as_str()) { ++ if !id.trim().is_empty() { ++ return Ok(id.trim().to_string()); ++ } ++ } ++ } ++ } ++ Err(anyhow!( ++ "Smart IDE project is not configured.\n\ ++\n\ ++Set one of:\n\ ++- [smart-ide].{KEY_PROJECT_ID} in settings.toml\n\ ++- projects/active-project.json (copy from projects/active-project.json.example)\n\ ++\n\ ++Example:\n\ ++[smart-ide]\n\ ++project_id = \"smart_ide\"" ++ )) ++} ++ ++pub fn resolve_env( ++ config: &LapceConfig, ++ workspace_root: Option<&Path>, ++) -> String { ++ let from_settings = plugin_string(config, KEY_ENV) ++ .unwrap_or_default() ++ .trim() ++ .to_string(); ++ if matches!(from_settings.as_str(), "test" | "pprod" | "prod") { ++ return from_settings; ++ } ++ if let Some(root) = workspace_root { ++ let repo_root = find_smart_ide_repo_root(root).unwrap_or_else(|| root.to_path_buf()); ++ if let Ok(v) = read_active_project_file(&repo_root) { ++ if let Some(env) = v.get("default_env").and_then(|v| v.as_str()) { ++ let env = env.trim(); ++ if matches!(env, "test" | "pprod" | "prod") { ++ return env.to_string(); ++ } ++ } ++ } ++ } ++ "test".to_string() ++} ++ ++fn read_project_conf(repo_root: &Path, project_id: &str) -> Result { ++ let p = repo_root ++ .join("projects") ++ .join(project_id) ++ .join("conf.json"); ++ let text = ++ fs::read_to_string(&p).with_context(|| format!("Failed to read {p:?}"))?; ++ serde_json::from_str(&text) ++ .with_context(|| format!("Failed to parse JSON in {p:?}")) ++} ++ ++pub fn resolve_preview_url( ++ config: &LapceConfig, ++ workspace_root: Option<&Path>, ++ env: &str, ++) -> Result> { ++ let repo_root = resolve_repo_root(workspace_root)?; ++ let project_id = resolve_project_id(config, Some(&repo_root))?; ++ let v = read_project_conf(&repo_root, &project_id)?; ++ let url = v ++ .get("smart_ide") ++ .and_then(|v| v.get("preview_urls")) ++ .and_then(|v| v.get(env)) ++ .and_then(|v| v.as_str()) ++ .map(|s| s.trim().to_string()) ++ .filter(|s| !s.is_empty()); ++ Ok(url) ++} ++ + fn http_client() -> Result { + Client::builder() + .timeout(Duration::from_secs(20)) +@@ -78,6 +270,113 @@ fn http_client() -> Result { + .context("Failed to build HTTP client") + } + ++fn health_check(url: &str) -> Result<()> { ++ let client = http_client()?; ++ let resp = client.get(url).send().context("Health check request failed")?; ++ let status = resp.status(); ++ if !status.is_success() { ++ let text = resp.text().unwrap_or_default(); ++ return Err(anyhow!( ++ "Health check failed (HTTP {status}):\n{text}" ++ )); ++ } ++ Ok(()) ++} ++ ++pub fn orchestrator_health(config: &LapceConfig) -> Result<()> { ++ health_check(&format!("{}/health", orchestrator_base_url(config))) ++} ++ ++pub fn ia_dev_gateway_health(config: &LapceConfig) -> Result<()> { ++ health_check(&format!("{}/health", ia_dev_gateway_base_url(config))) ++} ++ ++pub fn tools_bridge_health(config: &LapceConfig) -> Result<()> { ++ health_check(&format!("{}/health", tools_bridge_base_url(config))) ++} ++ ++#[derive(Debug, Clone, Deserialize)] ++pub struct SshTunnelPlan { ++ #[serde(rename = "projectId")] ++ pub project_id: String, ++ pub env: String, ++ #[serde(rename = "sshHostAlias")] ++ pub ssh_host_alias: String, ++ pub mode: String, ++ pub argv: Vec, ++ pub hint: String, ++} ++ ++pub fn ssh_tunnel_plan( ++ config: &LapceConfig, ++ workspace_root: Option<&Path>, ++ mode: &str, ++) -> Result { ++ let repo_root = resolve_repo_root(workspace_root)?; ++ let project_id = resolve_project_id(config, Some(&repo_root))?; ++ let env = resolve_env(config, Some(&repo_root)); ++ let script = repo_root ++ .join("scripts") ++ .join("smart-ide-ssh-tunnel-plan.sh"); ++ ++ let out = Command::new("bash") ++ .arg(script) ++ .arg("--project") ++ .arg(&project_id) ++ .arg("--env") ++ .arg(&env) ++ .arg("--mode") ++ .arg(mode) ++ .arg("--json") ++ .output() ++ .context("Failed to run smart-ide-ssh-tunnel-plan.sh")?; ++ ++ if !out.status.success() { ++ let stdout = String::from_utf8_lossy(&out.stdout); ++ let stderr = String::from_utf8_lossy(&out.stderr); ++ return Err(anyhow!( ++ "Tunnel plan script failed (exit={}):\n\nstdout:\n{}\n\nstderr:\n{}", ++ out.status.code().unwrap_or(1), ++ stdout, ++ stderr ++ )); ++ } ++ ++ let text = String::from_utf8(out.stdout).context("Tunnel plan output is not UTF-8")?; ++ serde_json::from_str(&text).context("Failed to parse tunnel plan JSON") ++} ++ ++fn shell_escape(arg: &str) -> String { ++ let safe = arg ++ .chars() ++ .all(|c| c.is_ascii_alphanumeric() || "_@%+=:,./-".contains(c)); ++ if safe && !arg.is_empty() { ++ return arg.to_string(); ++ } ++ let mut out = String::new(); ++ out.push('\''); ++ for ch in arg.chars() { ++ if ch == '\'' { ++ out.push_str("'\\''"); ++ } else { ++ out.push(ch); ++ } ++ } ++ out.push('\''); ++ out ++} ++ ++pub fn shell_command_from_argv(argv: &[String]) -> String { ++ let mut out = String::new(); ++ for (i, a) in argv.iter().enumerate() { ++ if i > 0 { ++ out.push(' '); ++ } ++ out.push_str(&shell_escape(a)); ++ } ++ out ++} ++ + pub fn orchestrator_execute(config: &LapceConfig, intent: &str) -> Result { + let base_url = orchestrator_base_url(config); + let token = orchestrator_token(config)?; +@@ -152,3 +451,137 @@ pub fn ia_dev_list_agents(config: &LapceConfig) -> Result { + serde_json::from_str(&text).context("Failed to parse ia-dev-gateway JSON response") + } + ++#[derive(Debug, Clone, Deserialize)] ++pub struct IaDevRunStartResponse { ++ #[serde(rename = "runId")] ++ pub run_id: String, ++ pub status: String, ++} ++ ++pub fn ia_dev_start_run( ++ config: &LapceConfig, ++ agent_id: &str, ++ project_id: &str, ++ intent: &str, ++ env: Option<&str>, ++ payload: Option<&Value>, ++) -> Result { ++ let base_url = ia_dev_gateway_base_url(config); ++ let token = ia_dev_gateway_token(config)?; ++ ++ let url = format!("{base_url}/v1/runs"); ++ let mut body = json!({ ++ "agentId": agent_id, ++ "projectId": project_id, ++ "intent": intent, ++ }); ++ if let Some(env) = env { ++ body["env"] = json!(env); ++ } ++ if let Some(payload) = payload { ++ body["payload"] = payload.clone(); ++ } ++ ++ let client = http_client()?; ++ let resp = client ++ .post(url) ++ .bearer_auth(token) ++ .json(&body) ++ .send() ++ .context("IA-dev-gateway start run request failed")?; ++ ++ let status = resp.status(); ++ let text = resp.text().context("Failed to read ia-dev-gateway response body")?; ++ if !status.is_success() { ++ return Err(anyhow!( ++ "IA-dev-gateway error (HTTP {status}):\n{text}" ++ )); ++ } ++ ++ serde_json::from_str(&text).context("Failed to parse ia-dev-gateway start run response") ++} ++ ++pub fn ia_dev_stream_run_events( ++ config: &LapceConfig, ++ run_id: &str, ++ last_event_id: Option, ++ mut on_event: impl FnMut(Value), ++) -> Result<()> { ++ let base_url = ia_dev_gateway_base_url(config); ++ let token = ia_dev_gateway_token(config)?; ++ let url = format!("{base_url}/v1/runs/{run_id}/events"); ++ ++ let client = http_client()?; ++ let mut req = client.get(url).bearer_auth(token); ++ if let Some(last_id) = last_event_id { ++ req = req.header("Last-Event-ID", last_id.to_string()); ++ } ++ let resp = req ++ .send() ++ .context("IA-dev-gateway events stream request failed")?; ++ ++ let status = resp.status(); ++ if !status.is_success() { ++ let text = resp ++ .text() ++ .context("Failed to read ia-dev-gateway events error body")?; ++ return Err(anyhow!( ++ "IA-dev-gateway error (HTTP {status}):\n{text}" ++ )); ++ } ++ ++ let mut reader = BufReader::new(resp); ++ let mut data_buf = String::new(); ++ let mut has_data = false; ++ ++ loop { ++ let mut line = String::new(); ++ let n = reader ++ .read_line(&mut line) ++ .context("Failed to read SSE line")?; ++ if n == 0 { ++ break; ++ } ++ let line = line.strip_suffix('\n').unwrap_or(&line); ++ let line = line.strip_suffix('\r').unwrap_or(line); ++ ++ if line.is_empty() { ++ if has_data { ++ let v: Value = ++ serde_json::from_str(&data_buf).context("Invalid SSE JSON data")?; ++ on_event(v); ++ } ++ data_buf.clear(); ++ has_data = false; ++ continue; ++ } ++ ++ if line.starts_with(':') { ++ continue; ++ } ++ ++ if line.starts_with("event:") { ++ continue; ++ } ++ if let Some(rest) = line.strip_prefix("data:") { ++ if !data_buf.is_empty() { ++ data_buf.push('\n'); ++ } ++ data_buf.push_str(rest.trim_start()); ++ has_data = true; ++ continue; ++ } ++ if line.starts_with("id:") || line.starts_with("retry:") { ++ continue; ++ } ++ } ++ ++ // Ensure we flush any trailing event without blank line (defensive). ++ if has_data { ++ let v: Value = serde_json::from_str(&data_buf).context("Invalid SSE JSON data")?; ++ on_event(v); ++ } ++ ++ Ok(()) ++} ++ +diff --git a/lapce-app/src/smart_ide_ui.rs b/lapce-app/src/smart_ide_ui.rs +new file mode 100644 +index 0000000..7a9d451 +--- /dev/null ++++ b/lapce-app/src/smart_ide_ui.rs +@@ -0,0 +1,192 @@ ++use std::sync::mpsc::{Sender, channel}; ++ ++use floem::{ ++ ext_event::create_signal_from_channel, ++ reactive::{RwSignal, Scope, SignalUpdate, SignalWith}, ++}; ++ ++use im::Vector; ++ ++const MAX_RUNS: usize = 50; ++const MAX_LOG_LINES_PER_RUN: usize = 5_000; ++ ++#[derive(Clone, Copy, Debug, PartialEq, Eq)] ++pub enum SmartIdeTab { ++ Agents, ++ Runs, ++ Services, ++ Connection, ++} ++ ++#[derive(Clone, Copy, Debug, PartialEq, Eq)] ++pub enum SmartIdeRunLogKind { ++ Event, ++ Stdout, ++ Stderr, ++} ++ ++#[derive(Clone, Debug, PartialEq, Eq)] ++pub struct SmartIdeRunLogLine { ++ pub id: i64, ++ pub kind: SmartIdeRunLogKind, ++ pub text: String, ++} ++ ++#[derive(Clone, Debug, PartialEq, Eq)] ++pub struct SmartIdeRun { ++ pub run_id: String, ++ pub agent_id: String, ++ pub project_id: String, ++ pub intent: String, ++ pub env: Option, ++ pub status: String, ++ pub started_at: String, ++ pub finished_at: Option, ++ pub exit_code: Option, ++ pub summary: Option, ++ pub error: Option, ++ pub logs: Vector, ++} ++ ++#[derive(Clone, Debug)] ++pub enum SmartIdeUiEvent { ++ UpsertRun { ++ run_id: String, ++ agent_id: String, ++ project_id: String, ++ intent: String, ++ env: Option, ++ status: String, ++ started_at: String, ++ finished_at: Option, ++ exit_code: Option, ++ summary: Option, ++ error: Option, ++ }, ++ AppendLog { ++ run_id: String, ++ line: SmartIdeRunLogLine, ++ }, ++} ++ ++#[derive(Clone)] ++pub struct SmartIdeData { ++ pub active_tab: RwSignal, ++ pub selected_run_id: RwSignal>, ++ pub runs: RwSignal>, ++ tx: Sender, ++} ++ ++impl SmartIdeData { ++ pub fn new(cx: Scope) -> Self { ++ let active_tab = cx.create_rw_signal(SmartIdeTab::Agents); ++ let selected_run_id = cx.create_rw_signal(None); ++ let runs = cx.create_rw_signal(Vector::::new()); ++ ++ let (tx, rx) = channel::(); ++ let notification = create_signal_from_channel(rx); ++ ++ { ++ let selected_run_id = selected_run_id; ++ let runs_signal = runs; ++ cx.create_effect(move |_| { ++ notification.with(|ev| { ++ let Some(ev) = ev.as_ref() else { ++ return; ++ }; ++ match ev { ++ SmartIdeUiEvent::UpsertRun { ++ run_id, ++ agent_id, ++ project_id, ++ intent, ++ env, ++ status, ++ started_at, ++ finished_at, ++ exit_code, ++ summary, ++ error, ++ } => { ++ runs_signal.update(|runs| { ++ let mut next = Vector::new(); ++ let mut found = false; ++ for r in runs.iter() { ++ if r.run_id == *run_id { ++ let mut updated = r.clone(); ++ updated.agent_id = agent_id.clone(); ++ updated.project_id = project_id.clone(); ++ updated.intent = intent.clone(); ++ updated.env = env.clone(); ++ updated.status = status.clone(); ++ updated.started_at = started_at.clone(); ++ updated.finished_at = finished_at.clone(); ++ updated.exit_code = *exit_code; ++ updated.summary = summary.clone(); ++ updated.error = error.clone(); ++ next.push_back(updated); ++ found = true; ++ } else { ++ next.push_back(r.clone()); ++ } ++ } ++ if !found { ++ next.push_back(SmartIdeRun { ++ run_id: run_id.clone(), ++ agent_id: agent_id.clone(), ++ project_id: project_id.clone(), ++ intent: intent.clone(), ++ env: env.clone(), ++ status: status.clone(), ++ started_at: started_at.clone(), ++ finished_at: finished_at.clone(), ++ exit_code: *exit_code, ++ summary: summary.clone(), ++ error: error.clone(), ++ logs: Vector::new(), ++ }); ++ selected_run_id.set(Some(run_id.clone())); ++ } ++ ++ while next.len() > MAX_RUNS { ++ next.pop_front(); ++ } ++ *runs = next; ++ }); ++ } ++ SmartIdeUiEvent::AppendLog { run_id, line } => { ++ runs_signal.update(|runs| { ++ let mut next = Vector::new(); ++ for r in runs.iter() { ++ if r.run_id == *run_id { ++ let mut updated = r.clone(); ++ updated.logs.push_back(line.clone()); ++ while updated.logs.len() > MAX_LOG_LINES_PER_RUN { ++ updated.logs.pop_front(); ++ } ++ next.push_back(updated); ++ } else { ++ next.push_back(r.clone()); ++ } ++ } ++ *runs = next; ++ }); ++ } ++ } ++ }); ++ }); ++ } ++ ++ Self { ++ active_tab, ++ selected_run_id, ++ runs, ++ tx, ++ } ++ } ++ ++ pub fn tx(&self) -> Sender { ++ self.tx.clone() ++ } ++} ++ +diff --git a/lapce-app/src/window_tab.rs b/lapce-app/src/window_tab.rs +index 857a215..69a942a 100644 +--- a/lapce-app/src/window_tab.rs ++++ b/lapce-app/src/window_tab.rs +@@ -48,7 +48,7 @@ + CodeActionOrCommand, CodeLens, Diagnostic, ProgressParams, ProgressToken, + ShowMessageParams, + }; +-use serde_json::Value; ++use serde_json::{Value, json}; + use tracing::{Level, debug, error, event}; + + use crate::{ +@@ -72,6 +72,10 @@ + hover::HoverData, + id::WindowTabId, + inline_completion::InlineCompletionData, ++ smart_ide_ui::{ ++ SmartIdeData, SmartIdeRunLogKind, SmartIdeRunLogLine, SmartIdeTab, ++ SmartIdeUiEvent, ++ }, + keypress::{EventRef, KeyPressData, KeyPressFocus, condition::Condition}, + listener::Listener, + lsp::path_from_url, +@@ -181,6 +185,7 @@ pub struct WindowTabData { + pub source_control: SourceControlData, + pub rename: RenameData, + pub global_search: GlobalSearchData, ++ pub smart_ide: SmartIdeData, + pub call_hierarchy_data: CallHierarchyData, + pub about_data: AboutData, + pub alert_data: AlertBoxData, +@@ -511,6 +516,7 @@ pub fn new( + + let rename = RenameData::new(cx, main_split.editors, common.clone()); + let global_search = GlobalSearchData::new(cx, main_split.clone()); ++ let smart_ide = SmartIdeData::new(cx); + + let plugin = PluginData::new( + cx, +@@ -558,6 +564,7 @@ pub fn new( + plugin, + rename, + global_search, ++ smart_ide, + call_hierarchy_data: CallHierarchyData { + root: cx.create_rw_signal(None), + common: common.clone(), +@@ -807,6 +814,339 @@ fn smart_ide_list_agents(&self) { + .ok(); + } + ++ fn smart_ide_open_panel(&self) { ++ self.panel.show_panel(&PanelKind::SmartIde); ++ self.common.focus.set(Focus::Panel(PanelKind::SmartIde)); ++ } ++ ++ fn smart_ide_run_pinned_action(&self, index: usize) { ++ let config = self.common.config.get_untracked(); ++ let actions = crate::smart_ide::pinned_actions(&config); ++ let Some(action) = actions.get(index) else { ++ self.common.internal_command.send(InternalCommand::ShowAlert { ++ title: "Smart IDE".to_string(), ++ msg: format!("No pinned action configured at position {}.", index + 1), ++ buttons: Vec::new(), ++ }); ++ return; ++ }; ++ self.smart_ide_execute_action(action.clone()); ++ } ++ ++ pub fn smart_ide_execute_action(&self, action: String) { ++ if let Some(rest) = action.strip_prefix("smart-ide:") { ++ match rest { ++ "tunnel" => { ++ self.smart_ide_open_panel(); ++ self.smart_ide.active_tab.set(SmartIdeTab::Connection); ++ return; ++ } ++ "open" => { ++ self.smart_ide_open_panel(); ++ return; ++ } ++ _ => { ++ self.common.internal_command.send(InternalCommand::ShowAlert { ++ title: "Smart IDE".to_string(), ++ msg: format!("Unknown Smart IDE action: {action}"), ++ buttons: Vec::new(), ++ }); ++ return; ++ } ++ } ++ } ++ ++ let (agent_id, env_override) = if let Some((a, b)) = action.split_once(':') { ++ let b = b.trim(); ++ if b == "test" || b == "pprod" || b == "prod" { ++ (a.trim().to_string(), Some(b.to_string())) ++ } else { ++ (action.trim().to_string(), None) ++ } ++ } else { ++ (action.trim().to_string(), None) ++ }; ++ ++ if agent_id.is_empty() { ++ self.common.internal_command.send(InternalCommand::ShowAlert { ++ title: "Smart IDE".to_string(), ++ msg: "Invalid action: empty agent id.".to_string(), ++ buttons: Vec::new(), ++ }); ++ return; ++ } ++ ++ if agent_id == "deploy-by-script" { ++ let Some(env) = env_override.clone() else { ++ self.common.internal_command.send(InternalCommand::ShowAlert { ++ title: "Smart IDE".to_string(), ++ msg: "Deploy requires an environment (test|pprod|prod).".to_string(), ++ buttons: Vec::new(), ++ }); ++ return; ++ }; ++ if env == "pprod" || env == "prod" { ++ let this = self.clone(); ++ let label = format!("Deploy to {env}"); ++ self.common.internal_command.send(InternalCommand::ShowAlert { ++ title: "Smart IDE".to_string(), ++ msg: format!("You are about to deploy to {env}. Continue?"), ++ buttons: vec![AlertButton { ++ text: label, ++ action: Rc::new(move || { ++ this.common ++ .internal_command ++ .send(InternalCommand::HideAlert); ++ this.smart_ide_start_ia_dev_run( ++ agent_id.clone(), ++ Some(env.clone()), ++ None, ++ ); ++ }), ++ }], ++ }); ++ return; ++ } ++ } ++ ++ let payload = if agent_id == "push-by-script" { ++ let commit_message = format!( ++ "chore: push by script\n\nTriggered from Smart IDE pinned action.\nAction: {action}\n" ++ ); ++ Some(json!({ "commitMessage": commit_message })) ++ } else { ++ None ++ }; ++ ++ self.smart_ide_start_ia_dev_run(agent_id, env_override, payload); ++ } ++ ++ fn smart_ide_start_ia_dev_run( ++ &self, ++ agent_id: String, ++ env_override: Option, ++ payload: Option, ++ ) { ++ self.smart_ide_open_panel(); ++ self.smart_ide.active_tab.set(SmartIdeTab::Runs); ++ ++ let config = self.common.config.get_untracked(); ++ let tx = self.smart_ide.tx(); ++ let workspace_root = self.workspace.path.clone(); ++ ++ let internal_command = self.common.internal_command; ++ let send_alert = create_ext_action(self.common.scope, move |msg: String| { ++ internal_command.send(InternalCommand::ShowAlert { ++ title: "Smart IDE".to_string(), ++ msg, ++ buttons: Vec::new(), ++ }); ++ }); ++ ++ std::thread::Builder::new() ++ .name("SmartIdeRunAgent".to_owned()) ++ .spawn(move || { ++ let project_id = match crate::smart_ide::resolve_project_id( ++ &config, ++ workspace_root.as_deref(), ++ ) { ++ Ok(id) => id, ++ Err(e) => { ++ send_alert(e.to_string()); ++ return; ++ } ++ }; ++ ++ let env_effective = match env_override.as_deref() { ++ Some("test" | "pprod" | "prod") => env_override.clone(), ++ Some(_) => None, ++ None => Some(crate::smart_ide::resolve_env(&config, workspace_root.as_deref())), ++ }; ++ ++ let intent = match env_effective.as_deref() { ++ Some(env) => format!("smart-ide:{agent_id}:{env}"), ++ None => format!("smart-ide:{agent_id}"), ++ }; ++ ++ let started_at = chrono::Utc::now().to_rfc3339(); ++ let started = match crate::smart_ide::ia_dev_start_run( ++ &config, ++ &agent_id, ++ &project_id, ++ &intent, ++ env_effective.as_deref(), ++ payload.as_ref(), ++ ) { ++ Ok(v) => v, ++ Err(e) => { ++ send_alert(e.to_string()); ++ return; ++ } ++ }; ++ ++ let _ = tx.send(SmartIdeUiEvent::UpsertRun { ++ run_id: started.run_id.clone(), ++ agent_id: agent_id.clone(), ++ project_id: project_id.clone(), ++ intent: intent.clone(), ++ env: env_effective.clone(), ++ status: started.status.clone(), ++ started_at: started_at.clone(), ++ finished_at: None, ++ exit_code: None, ++ summary: None, ++ error: None, ++ }); ++ ++ let run_id = started.run_id.clone(); ++ let started_at_for_events = started_at.clone(); ++ let env_for_events = env_effective.clone(); ++ ++ let stream_result = crate::smart_ide::ia_dev_stream_run_events( ++ &config, ++ &run_id, ++ None, ++ |ev| { ++ let ev_id = ev.get("id").and_then(|v| v.as_i64()).unwrap_or(0); ++ let ev_type = ev ++ .get("type") ++ .and_then(|v| v.as_str()) ++ .unwrap_or("event"); ++ let at = ev ++ .get("at") ++ .and_then(|v| v.as_str()) ++ .unwrap_or(""); ++ let data = ev.get("data").cloned().unwrap_or(Value::Null); ++ ++ let mut status_update: Option<(String, Option, Option)> = ++ None; ++ ++ let (kind, text) = match ev_type { ++ "script_stdout" => ( ++ SmartIdeRunLogKind::Stdout, ++ data.get("line") ++ .and_then(|v| v.as_str()) ++ .unwrap_or("") ++ .to_string(), ++ ), ++ "script_stderr" => ( ++ SmartIdeRunLogKind::Stderr, ++ data.get("line") ++ .and_then(|v| v.as_str()) ++ .unwrap_or("") ++ .to_string(), ++ ), ++ "started" => { ++ status_update = Some(( ++ "running".to_string(), ++ None, ++ None, ++ )); ++ ( ++ SmartIdeRunLogKind::Event, ++ format!("[{at}] started"), ++ ) ++ } ++ "script_started" => { ++ let display = data ++ .get("displayName") ++ .and_then(|v| v.as_str()) ++ .unwrap_or("script"); ++ ( ++ SmartIdeRunLogKind::Event, ++ format!("[{at}] script_started: {display}"), ++ ) ++ } ++ "completed" => { ++ let exit_code = data.get("exitCode").and_then(|v| v.as_i64()); ++ status_update = Some(( ++ "completed".to_string(), ++ exit_code, ++ None, ++ )); ++ ( ++ SmartIdeRunLogKind::Event, ++ format!("[{at}] completed (exitCode={})", exit_code.unwrap_or(0)), ++ ) ++ } ++ "failed" => { ++ let exit_code = data.get("exitCode").and_then(|v| v.as_i64()); ++ let err = data ++ .get("error") ++ .and_then(|v| v.as_str()) ++ .map(|s| s.to_string()); ++ status_update = Some(( ++ "failed".to_string(), ++ exit_code, ++ err.clone(), ++ )); ++ ( ++ SmartIdeRunLogKind::Event, ++ format!( ++ "[{at}] failed (exitCode={}, error={})", ++ exit_code.unwrap_or(1), ++ err.unwrap_or_else(|| "unknown".to_string()) ++ ), ++ ) ++ } ++ _ => ( ++ SmartIdeRunLogKind::Event, ++ format!("[{at}] {ev_type}"), ++ ), ++ }; ++ ++ if !text.is_empty() { ++ let _ = tx.send(SmartIdeUiEvent::AppendLog { ++ run_id: run_id.clone(), ++ line: SmartIdeRunLogLine { ++ id: ev_id, ++ kind, ++ text, ++ }, ++ }); ++ } ++ ++ if let Some((status, exit_code, error)) = status_update { ++ let finished_at = if status == "completed" || status == "failed" { ++ if at.is_empty() { ++ Some(chrono::Utc::now().to_rfc3339()) ++ } else { ++ Some(at.to_string()) ++ } ++ } else { ++ None ++ }; ++ let _ = tx.send(SmartIdeUiEvent::UpsertRun { ++ run_id: run_id.clone(), ++ agent_id: agent_id.clone(), ++ project_id: project_id.clone(), ++ intent: intent.clone(), ++ env: env_for_events.clone(), ++ status, ++ started_at: started_at_for_events.clone(), ++ finished_at, ++ exit_code, ++ summary: None, ++ error, ++ }); ++ } ++ }, ++ ); ++ ++ if let Err(e) = stream_result { ++ let _ = tx.send(SmartIdeUiEvent::AppendLog { ++ run_id: started.run_id.clone(), ++ line: SmartIdeRunLogLine { ++ id: -1, ++ kind: SmartIdeRunLogKind::Event, ++ text: format!("events stream error: {}", e), ++ }, ++ }); ++ } ++ }) ++ .ok(); ++ } ++ + pub fn run_workbench_command( + &self, + cmd: LapceWorkbenchCommand, +@@ -1239,6 +1579,16 @@ pub fn run_workbench_command( + // ==== Smart IDE ==== + SmartIdeTimeline => self.smart_ide_open_timeline(), + SmartIdeListAgents => self.smart_ide_list_agents(), ++ SmartIdeOpenPanel => self.smart_ide_open_panel(), ++ SmartIdePinnedAction1 => self.smart_ide_run_pinned_action(0), ++ SmartIdePinnedAction2 => self.smart_ide_run_pinned_action(1), ++ SmartIdePinnedAction3 => self.smart_ide_run_pinned_action(2), ++ SmartIdePinnedAction4 => self.smart_ide_run_pinned_action(3), ++ SmartIdePinnedAction5 => self.smart_ide_run_pinned_action(4), ++ SmartIdePinnedAction6 => self.smart_ide_run_pinned_action(5), ++ SmartIdePinnedAction7 => self.smart_ide_run_pinned_action(6), ++ SmartIdePinnedAction8 => self.smart_ide_run_pinned_action(7), ++ SmartIdePinnedAction9 => self.smart_ide_run_pinned_action(8), + + // ==== Running / Debugging ==== + RunAndDebugRestart => { +@@ -2734,7 +3084,10 @@ fn toggle_panel_focus(&self, kind: PanelKind) { + // in those cases. + self.panel.is_panel_visible(&kind) + } +- PanelKind::Terminal | PanelKind::SourceControl | PanelKind::Search => { ++ PanelKind::Terminal ++ | PanelKind::SourceControl ++ | PanelKind::Search ++ | PanelKind::SmartIde => { + self.is_panel_focused(kind) + } + }; diff --git a/patches/lapce/0003-fix-handle-notify-send-errors.patch b/patches/lapce/0003-fix-handle-notify-send-errors.patch new file mode 100644 index 0000000..066effd --- /dev/null +++ b/patches/lapce/0003-fix-handle-notify-send-errors.patch @@ -0,0 +1,30 @@ +From 198ee3928f5288c8c8d9ad7cb149616411066328 Mon Sep 17 00:00:00 2001 +From: Nicolas Cantu +Date: Mon, 6 Apr 2026 13:02:18 +0200 +Subject: [PATCH 3/3] fix: handle notify-send errors + +Log failures when spawning notify-send on unix to avoid unused-variable warnings and make errors visible. + +diff --git a/lapce-app/src/app/logging.rs b/lapce-app/src/app/logging.rs +index bbc0586..335ddb1 100644 +--- a/lapce-app/src/app/logging.rs ++++ b/lapce-app/src/app/logging.rs +@@ -137,7 +137,7 @@ pub(super) fn error_modal(title: &str, msg: &str) -> i32 { + + #[cfg(unix)] + pub fn error_notification(title: &str, msg: &str) { +- let res = std::process::Command::new("notify-send") ++ if let Err(e) = std::process::Command::new("notify-send") + .args([ + "-a", + "dev.lapce.lapce", +@@ -149,5 +149,8 @@ pub fn error_notification(title: &str, msg: &str) { + title, + msg, + ]) +- .spawn(); ++ .spawn() ++ { ++ tracing::warn!("notify-send failed: {e}"); ++ } + } diff --git a/patches/lapce/series b/patches/lapce/series index 74d7bfa..22727c7 100644 --- a/patches/lapce/series +++ b/patches/lapce/series @@ -1,3 +1,6 @@ # List Lapce patch files (one per line). # This file is consumed by: ./scripts/core-ide-apply-patches.sh +0001-feat-add-Smart-IDE-intents-palette-and-scratch-actio.patch +0002-feat-add-Smart-IDE-cockpit-panel.patch +0003-fix-handle-notify-send-errors.patch diff --git a/services/ia-dev-gateway/src/server.ts b/services/ia-dev-gateway/src/server.ts index 712e012..2347c6b 100644 --- a/services/ia-dev-gateway/src/server.ts +++ b/services/ia-dev-gateway/src/server.ts @@ -62,22 +62,82 @@ const envLiteral = (v: unknown): "test" | "pprod" | "prod" | undefined => { return undefined; }; -const listAgents = (): { id: string; name: string; summary: string; triggerCommands: string[] }[] => { +type AgentListItem = { + id: string; + name: string; + summary: string; + description?: string; + triggerCommands: string[]; +}; + +const parseYamlFrontmatter = (markdown: string): Record => { + const lines = markdown.split(/\r?\n/); + if (lines.length < 3 || lines[0].trim() !== "---") { + return {}; + } + let end = -1; + for (let i = 1; i < lines.length; i++) { + if (lines[i].trim() === "---") { + end = i; + break; + } + } + if (end < 0) { + return {}; + } + const out: Record = {}; + for (let i = 1; i < end; i++) { + const raw = lines[i]; + const m = /^([A-Za-z0-9_-]+)\s*:\s*(.*)\s*$/.exec(raw); + if (!m) { + continue; + } + const key = m[1]; + let value = m[2] ?? ""; + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + out[key] = value; + } + return out; +}; + +const listAgents = (): AgentListItem[] => { const root = getIaDevRoot(); const dir = agentsDir(root); if (!dirExists(dir)) { return []; } - const out: { id: string; name: string; summary: string; triggerCommands: string[] }[] = []; + const out: AgentListItem[] = []; for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { if (!ent.isFile() || !ent.name.endsWith(".md")) { continue; } const id = ent.name.replace(/\.md$/i, ""); + let name = id; + let description: string | undefined; + try { + const p = path.join(dir, ent.name); + const text = fs.readFileSync(p, "utf8"); + const fm = parseYamlFrontmatter(text); + if (typeof fm.name === "string" && fm.name.trim().length > 0) { + name = fm.name.trim(); + } + if (typeof fm.description === "string" && fm.description.trim().length > 0) { + description = fm.description.trim(); + } + } catch { + // ignore per-file parse errors + } + const summary = description ?? `Agent definition ${ent.name}`; out.push({ id, - name: id, - summary: `Agent definition ${ent.name}`, + name, + summary, + description, triggerCommands: [], }); }