smart_ide/patches/lapce/0002-feat-add-Smart-IDE-cockpit-panel.patch
Nicolas Cantu ca03324838 feat: update Lapce patch series for Smart IDE panel
**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
2026-04-06 13:03:34 +02:00

2279 lines
85 KiB
Diff

From 43ee9102b485ed6cdc9d67131376d5738446c788 Mon Sep 17 00:00:00 2001
From: Nicolas Cantu <nicolas.cantu@pm.me>
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<Arc<LapceConfig>>,
+) -> 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<Arc<LapceConfig>>,
+) -> 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<WindowTabData>,
+ _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::<SmartIdeAgent>::new());
+ let agents_loading = create_rw_signal(false);
+ let agents_error = create_rw_signal(None::<String>);
+
+ 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::<String>);
+ 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<dyn Fn()> = {
+ 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<Vector<SmartIdeAgent>, 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<dyn Fn()> = {
+ 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<dyn Fn()> = {
+ 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<dyn Fn(String)> = {
+ 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(|_| "<missing project>".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<dyn Fn(String)> = {
+ 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/<id>/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<String> {
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<String> {
+ 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<PathBuf> {
+ 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<PathBuf> {
+ 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<String> {
})
}
+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<String> {
+ 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<String> {
+ 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<Value> {
+ 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<String> {
+ 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<Value> {
+ 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<Option<String>> {
+ 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> {
Client::builder()
.timeout(Duration::from_secs(20))
@@ -78,6 +270,113 @@ fn http_client() -> Result<Client> {
.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<String>,
+ pub hint: String,
+}
+
+pub fn ssh_tunnel_plan(
+ config: &LapceConfig,
+ workspace_root: Option<&Path>,
+ mode: &str,
+) -> Result<SshTunnelPlan> {
+ 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<Value> {
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<Value> {
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<IaDevRunStartResponse> {
+ 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<i64>,
+ 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<String>,
+ pub status: String,
+ pub started_at: String,
+ pub finished_at: Option<String>,
+ pub exit_code: Option<i64>,
+ pub summary: Option<String>,
+ pub error: Option<String>,
+ pub logs: Vector<SmartIdeRunLogLine>,
+}
+
+#[derive(Clone, Debug)]
+pub enum SmartIdeUiEvent {
+ UpsertRun {
+ run_id: String,
+ agent_id: String,
+ project_id: String,
+ intent: String,
+ env: Option<String>,
+ status: String,
+ started_at: String,
+ finished_at: Option<String>,
+ exit_code: Option<i64>,
+ summary: Option<String>,
+ error: Option<String>,
+ },
+ AppendLog {
+ run_id: String,
+ line: SmartIdeRunLogLine,
+ },
+}
+
+#[derive(Clone)]
+pub struct SmartIdeData {
+ pub active_tab: RwSignal<SmartIdeTab>,
+ pub selected_run_id: RwSignal<Option<String>>,
+ pub runs: RwSignal<Vector<SmartIdeRun>>,
+ tx: Sender<SmartIdeUiEvent>,
+}
+
+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::<SmartIdeRun>::new());
+
+ let (tx, rx) = channel::<SmartIdeUiEvent>();
+ 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<SmartIdeUiEvent> {
+ 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<String>,
+ payload: Option<Value>,
+ ) {
+ 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<i64>, Option<String>)> =
+ 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)
}
};