diff --git a/nebula_app/assets/icons/nebula-shield.svg b/nebula_app/assets/icons/nebula-shield.svg
new file mode 100644
index 000000000..4b3e9aac8
--- /dev/null
+++ b/nebula_app/assets/icons/nebula-shield.svg
@@ -0,0 +1,3 @@
+
diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json
index 2207b8e4e..9847188c3 100644
--- a/nebula_app/i18n/en-US.json
+++ b/nebula_app/i18n/en-US.json
@@ -1013,6 +1013,7 @@
"tab_color": "Tab color"
},
"chrome": {
+ "local_administrator": "Local Pebrel process is running as Windows administrator",
"new_terminal_ctrl_shift_t": "New terminal (Ctrl+Shift+T)",
"more": "More",
"scroll_tabs_left": "Scroll tabs left",
diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json
index a4e32fd51..b4019ed7a 100644
--- a/nebula_app/i18n/zh-CN.json
+++ b/nebula_app/i18n/zh-CN.json
@@ -1013,6 +1013,7 @@
"tab_color": "标签颜色"
},
"chrome": {
+ "local_administrator": "本机 Pebrel 进程正以 Windows 管理员身份运行",
"new_terminal_ctrl_shift_t": "新建终端 (Ctrl+Shift+T)",
"more": "更多",
"scroll_tabs_left": "向左翻标签",
diff --git a/nebula_app/src/gpui_shell/assets.rs b/nebula_app/src/gpui_shell/assets.rs
index 3c4727dc4..e96e9756b 100644
--- a/nebula_app/src/gpui_shell/assets.rs
+++ b/nebula_app/src/gpui_shell/assets.rs
@@ -41,6 +41,7 @@ const NEBULA_ICONS: &[(&str, &[u8])] = icons![
"pin",
"pencil",
"clipboard-paste",
+ "shield",
"trash-2",
"refresh",
"phone",
@@ -109,6 +110,7 @@ pub mod nav {
/// Lucide pencil;固定组件资产集未收录,命令行内编辑动作需要明确图形语义。
pub const PENCIL: &str = "icons/nebula-pencil.svg";
pub const CLIPBOARD_PASTE: &str = "icons/nebula-clipboard-paste.svg";
+ pub const SHIELD: &str = "icons/nebula-shield.svg";
/// Lucide trash-2;删除保存命令不能借用表示 Backspace 的 `IconName::Delete`。
pub const TRASH: &str = "icons/nebula-trash-2.svg";
pub const REFRESH: &str = "icons/nebula-refresh.svg";
diff --git a/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs b/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs
index ef3df6eea..13913ffa2 100644
--- a/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs
+++ b/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs
@@ -1248,3 +1248,36 @@ fn a_failed_codex_chooser_does_not_start_an_automatic_retry_loop(cx: &mut TestAp
assert!(receiver.try_iter().all(|message| !matches!(message, Msg::Input(_))));
});
}
+
+#[gpui::test]
+fn host_administrator_scope_excludes_remote_and_wsl_shells(cx: &mut TestAppContext) {
+ let (view, window, _) = open(cx);
+ view.update(window, |view, _| {
+ let context = |program: &str| {
+ crate::runtime_exec::PaneExecContext::from_pty_options(&nebula_terminal::tty::Options {
+ shell: Some(nebula_terminal::tty::Shell::new(program.into(), vec![])),
+ ..Default::default()
+ })
+ };
+ view.suggest.suggest_env = crate::display::SuggestEnv::Local;
+ view.exec_context = Some(context("pwsh.exe"));
+ assert!(view.inherits_windows_host_token());
+ view.suggest.suggest_env = crate::display::SuggestEnv::Shell {
+ scope: crate::nebula_history::HistoryScope::Ssh("user@remote".into()),
+ };
+ assert!(!view.inherits_windows_host_token());
+ view.suggest.suggest_env = crate::display::SuggestEnv::Shell {
+ scope: crate::nebula_history::HistoryScope::Wsl("Ubuntu".into()),
+ };
+ assert!(!view.inherits_windows_host_token());
+ view.suggest.suggest_env = crate::display::SuggestEnv::Local;
+ view.exec_context = Some(context("wsl.exe"));
+ assert!(!view.inherits_windows_host_token());
+ view.exec_context = Some(context("pwsh.exe"));
+ view.ssh_destination = Some("user@remote".into());
+ assert!(!view.inherits_windows_host_token());
+ view.ssh_destination = None;
+ view.exec_context = None;
+ assert!(!view.inherits_windows_host_token());
+ });
+}
diff --git a/nebula_app/src/gpui_shell/terminal/view/tab_identity.rs b/nebula_app/src/gpui_shell/terminal/view/tab_identity.rs
index b498f67e7..b47058dbb 100644
--- a/nebula_app/src/gpui_shell/terminal/view/tab_identity.rs
+++ b/nebula_app/src/gpui_shell/terminal/view/tab_identity.rs
@@ -11,6 +11,18 @@ fn ssh_label(destination: Option<&str>, directory: &std::path::Path) -> Option bool {
+ self.ssh_destination.is_none()
+ && self
+ .exec_context
+ .as_ref()
+ .is_some_and(|context| context.wsl_distribution().is_none())
+ && matches!(
+ self.suggest.suggest_env.history_scope(),
+ crate::nebula_history::HistoryScope::Local
+ )
+ }
+
/// 目录来自 shell 上报;不要从带 Powerline 图标的提示符或选区反推路径。
pub fn working_directory(&self) -> Option<&str> {
let path = self.cwd.as_str();
diff --git a/nebula_app/src/gpui_shell/workspace/settings_navigation.rs b/nebula_app/src/gpui_shell/workspace/settings_navigation.rs
index 06b30a72f..cb6718d0f 100644
--- a/nebula_app/src/gpui_shell/workspace/settings_navigation.rs
+++ b/nebula_app/src/gpui_shell/workspace/settings_navigation.rs
@@ -169,6 +169,7 @@ impl NebulaWorkspace {
color: None,
renaming: None,
pane_count: 0,
+ local_administrator: false,
}
}
diff --git a/nebula_app/src/gpui_shell/workspace/sidebar.rs b/nebula_app/src/gpui_shell/workspace/sidebar.rs
index dd8ec82fb..3694786a9 100644
--- a/nebula_app/src/gpui_shell/workspace/sidebar.rs
+++ b/nebula_app/src/gpui_shell/workspace/sidebar.rs
@@ -241,6 +241,7 @@ impl NebulaWorkspace {
color: tab_color,
renaming,
pane_count,
+ local_administrator,
} = self.tab_presentation(ix, cx, dark);
let hover_group: SharedString = format!("sidebar-tab-hover-{ix}").into();
let has_program_glyph = program_glyph.is_some();
@@ -423,6 +424,13 @@ impl NebulaWorkspace {
.bg(color),
)
})
+ // 权限属于整个会话,先于程序身份呈现,且不被 AI 图标替换。
+ .when(local_administrator, |row| {
+ row.child(tab_presentation::administrator_badge(
+ format!("sidebar-admin-{ix}").into(),
+ cx,
+ ))
+ })
// 行首图标的优先级:**先身份、后形态**。AI 品牌图 / 程序字位
// 表达「这个 tab 里在跑什么」,它必须跟随聚焦 pane;2×2
// 分屏标记只在没有身份可显示时补位。
@@ -966,8 +974,15 @@ impl NebulaWorkspace {
let chrome_family = theme.mono_font_family.clone();
let symbol_family: SharedString = crate::font_install::REQUIRED_FONT_FAMILY.into();
let label_px = settings.map(|settings| settings.ui_font_size_px).unwrap_or(15.0);
- let TabPresentation { title, logo_image, logo_pending, program_glyph, pane_count, .. } =
- self.tab_presentation(self.active, cx, dark);
+ let TabPresentation {
+ title,
+ logo_image,
+ logo_pending,
+ program_glyph,
+ pane_count,
+ local_administrator,
+ ..
+ } = self.tab_presentation(self.active, cx, dark);
slot.child(
h_flex()
.absolute()
@@ -977,6 +992,12 @@ impl NebulaWorkspace {
.gap_2()
// 两侧工具靠 flex 天然让位,这点内缩只是别让长标题贴到按钮上。
.px_4()
+ .when(local_administrator, |row| {
+ row.child(tab_presentation::administrator_badge(
+ "collapsed-title-admin".into(),
+ cx,
+ ))
+ })
.when_some(logo_image, |row, image| {
row.child(
img(image)
diff --git a/nebula_app/src/gpui_shell/workspace/tab_presentation.rs b/nebula_app/src/gpui_shell/workspace/tab_presentation.rs
index 0f6d6dc22..ef405eb17 100644
--- a/nebula_app/src/gpui_shell/workspace/tab_presentation.rs
+++ b/nebula_app/src/gpui_shell/workspace/tab_presentation.rs
@@ -42,6 +42,7 @@ pub(super) struct TabPresentation {
/// 本 tab 的分屏数(Terminal tab 才 > 0)。`> 1` 时行首图标换成 2×2 分屏
/// 标记、行尾挂一枚数量胶囊;见 [`pane_header::split_badge`]。
pub(super) pane_count: usize,
+ pub(super) local_administrator: bool,
}
impl NebulaWorkspace {
@@ -77,6 +78,10 @@ impl NebulaWorkspace {
let title = self.tab_title(ix, cx);
let is_settings = self.tabs[ix].is_settings();
let is_terminal = self.tabs[ix].is_terminal();
+ let local_administrator = crate::platform::elevation::is_elevated().unwrap_or(false)
+ && self.tabs[ix]
+ .focused_view()
+ .is_some_and(|view| view.read(cx).inherits_windows_host_token());
let pane_count = match &self.tabs[ix] {
WorkspaceTab::Terminal { panes, .. } => panes.len(),
_ => 0,
@@ -142,6 +147,7 @@ impl NebulaWorkspace {
color: meta.color,
renaming,
pane_count,
+ local_administrator,
}
}
}
@@ -152,3 +158,90 @@ pub(super) fn tooltip(text: SharedString, window: &mut Window, cx: &mut App) ->
})
.build(window, cx)
}
+
+pub(super) fn administrator_badge(selector: SharedString, cx: &App) -> gpui::Stateful {
+ let label = crate::gpui_shell::config::ui_language(cx)
+ .text(crate::i18n::Message::ChromeLocalAdministrator);
+ div()
+ .id(selector.clone())
+ .debug_selector(move || selector.to_string())
+ .role(gpui::accesskit::Role::Image)
+ .aria_label(label)
+ .flex_shrink_0()
+ .size(px(TAB_LABEL_ICON_SIZE))
+ .flex()
+ .items_center()
+ .justify_center()
+ // 权限提示与任务图标同尺寸;矢量图避免 emoji 字体改变颜色和几何。
+ .child(
+ Icon::new(Icon::empty())
+ .path(crate::gpui_shell::assets::nav::SHIELD)
+ .size(px(TAB_LABEL_ICON_SIZE))
+ .text_color(cx.theme().warning),
+ )
+ .tooltip(move |window, cx| tooltip(label.into(), window, cx))
+}
+
+#[cfg(all(test, feature = "gpui-test-support"))]
+mod tests {
+ use super::*;
+ use gpui::{AssetSource as _, TestAppContext};
+ use gpui_component::Root;
+
+ struct BadgeSurface;
+
+ impl Render for BadgeSurface {
+ fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement {
+ h_flex()
+ .h(px(36.0))
+ .items_center()
+ .child(administrator_badge("test-administrator".into(), cx))
+ .child(
+ div()
+ .debug_selector(|| "test-task-icon".into())
+ .size(px(TAB_LABEL_ICON_SIZE))
+ .flex_shrink_0()
+ .child(Icon::new(IconName::SquareTerminal).size(px(TAB_LABEL_ICON_SIZE))),
+ )
+ .child(div().debug_selector(|| "test-admin-title".into()).child("pwsh"))
+ }
+ }
+
+ #[gpui::test]
+ fn administrator_badge_keeps_a_readable_slot_with_reduced_motion(cx: &mut TestAppContext) {
+ let asset = crate::gpui_shell::assets::NebulaAssets
+ .load(crate::gpui_shell::assets::nav::SHIELD)
+ .unwrap()
+ .expect("administrator shield must be embedded in the product");
+ assert!(std::str::from_utf8(&asset).unwrap().contains("viewBox=\"0 0 24 24\""));
+ cx.update(|cx| {
+ gpui_component::init(cx);
+ cx.set_global(crate::gpui_shell::config::Settings::load(
+ nebula_settings::ThemeName::Nord,
+ ));
+ });
+ let (_, window) = cx.add_window_view(|window, cx| {
+ let surface = cx.new(|_| BadgeSurface);
+ Root::new(surface, window, cx)
+ });
+ let mut previous = None;
+ for reduced_motion in [false, true] {
+ window.update(|window, cx| {
+ cx.set_reduce_motion(reduced_motion);
+ window.refresh();
+ window.draw(cx).clear(cx);
+ });
+ let badge = window.debug_bounds("test-administrator").unwrap();
+ let task = window.debug_bounds("test-task-icon").unwrap();
+ let title = window.debug_bounds("test-admin-title").unwrap();
+ assert_eq!(badge.size, gpui::size(px(TAB_LABEL_ICON_SIZE), px(TAB_LABEL_ICON_SIZE)));
+ assert_eq!(badge.size, task.size);
+ assert!(badge.right() <= task.left());
+ assert!(task.right() <= title.left());
+ if let Some(previous) = previous {
+ assert_eq!(badge, previous);
+ }
+ previous = Some(badge);
+ }
+ }
+}
diff --git a/nebula_app/src/gpui_shell/workspace/top_tabs.rs b/nebula_app/src/gpui_shell/workspace/top_tabs.rs
index 001b9dc2c..778aa22df 100644
--- a/nebula_app/src/gpui_shell/workspace/top_tabs.rs
+++ b/nebula_app/src/gpui_shell/workspace/top_tabs.rs
@@ -193,6 +193,7 @@ impl NebulaWorkspace {
color,
renaming,
pane_count,
+ local_administrator,
} = self.top_tab_presentation(ix, cx, dark);
let hover_group: SharedString = format!("top-tab-hover-{ix}").into();
let cross_window_drag = self.cross_window_drag_payload(ix, cx);
@@ -364,6 +365,12 @@ impl NebulaWorkspace {
.bg(color),
)
})
+ .when(local_administrator, |row| {
+ row.child(super::tab_presentation::administrator_badge(
+ format!("top-tab-admin-{ix}").into(),
+ cx,
+ ))
+ })
// 图标优先级与侧栏同源:先身份(跟随聚焦 pane),分屏标记
// 只在没有身份图标时补位。理由见 sidebar.rs 同处注释。
.when(is_settings, |row| {