From b17da849a8ce51589841f50b80176157ef2e1845 Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 21 Sep 2026 22:44:51 +0800 Subject: [PATCH 01/20] fix(ui): refine Agents and keep Markdown reading non-editable --- extra/logo/README.md | 13 +++ nebula_app/assets/icons/nebula-agent-grok.svg | 1 + .../assets/icons/nebula-agent-openai.svg | 1 + .../assets/icons/nebula-agent-opencode.svg | 1 + nebula_app/assets/icons/nebula-agent-pi.svg | 1 + nebula_app/i18n/en-US.json | 6 +- nebula_app/i18n/zh-CN.json | 6 +- nebula_app/i18n/zh-TW.json | 6 +- nebula_app/src/gpui_shell/assets.rs | 22 +++- .../src/gpui_shell/file_editor/activity.rs | 2 +- .../gpui_shell/file_editor/code_actions.rs | 28 ++++- .../file_editor/code_actions_tests.rs | 60 ++++++++++ .../file_editor/inline_live_tests.rs | 2 +- .../file_editor/inline_object_tests.rs | 4 +- .../gpui_shell/file_editor/live_commands.rs | 5 +- .../src/gpui_shell/file_editor/live_edit.rs | 17 +-- .../src/gpui_shell/file_editor/live_tests.rs | 18 +-- nebula_app/src/gpui_shell/file_editor/mod.rs | 7 +- .../src/gpui_shell/file_editor/preview.rs | 8 ++ .../file_editor/structure_commands.rs | 2 +- .../gpui_shell/file_editor/structure_tests.rs | 2 +- .../gpui_shell/file_editor/structure_view.rs | 7 +- .../src/gpui_shell/file_editor/tests.rs | 105 +++++++++++++++++- nebula_app/src/gpui_shell/settings_pane.rs | 10 +- .../src/gpui_shell/settings_pane/agents.rs | 102 +++++++++++------ .../gpui_shell/settings_pane/agents/tests.rs | 38 ++++++- .../gpui_shell/settings_pane/backup/tests.rs | 2 + .../gpui_shell/settings_pane/navigation.rs | 4 +- .../gpui_shell/settings_pane/search_header.rs | 6 +- .../src/gpui_shell/settings_pane/tests.rs | 12 +- .../src/gpui_shell/ssh_settings/library.rs | 54 ++++++++- .../gpui_shell/ssh_settings/library/tests.rs | 3 + nebula_app/src/gpui_shell/workspace.rs | 1 - .../workspace/settings_navigation.rs | 10 -- 34 files changed, 461 insertions(+), 105 deletions(-) create mode 100644 nebula_app/assets/icons/nebula-agent-grok.svg create mode 100644 nebula_app/assets/icons/nebula-agent-openai.svg create mode 100644 nebula_app/assets/icons/nebula-agent-opencode.svg create mode 100644 nebula_app/assets/icons/nebula-agent-pi.svg diff --git a/extra/logo/README.md b/extra/logo/README.md index e8543ef..fd822cd 100644 --- a/extra/logo/README.md +++ b/extra/logo/README.md @@ -92,6 +92,19 @@ by Git and are not required by the asset tests. ## Third-Party Logo Assets +### Agents settings vectors + +The Agents settings page renders 24 logical pixel SVG marks rather than enlarging +the sidebar PNG textures. Claude, Kimi and Oh My Pi reuse the SVG sources below; +Cursor and Copilot reuse the existing settings icons. OpenAI, OpenCode, Pi and +Grok use `nebula_app/assets/icons/nebula-agent-{openai,opencode,pi,grok}.svg`, +retrieved from the fixed npm package `@lobehub/icons-static-svg` 1.95.0: +`https://unpkg.com/@lobehub/icons-static-svg@1.95.0/icons/{name}.svg`. +These four sources are unchanged except for a final newline, under the collection's +[MIT license](LICENSE-lobe-icons). Product trademarks belong to their owners. +GPUI renders the SVG alpha masks using theme ink, with orange for Claude and +purple for Oh My Pi; sidebar PNG presentation is independent. + ### Cursor and GitHub Copilot settings icons `nebula_app/assets/icons/nebula-agent-cursor.svg` and diff --git a/nebula_app/assets/icons/nebula-agent-grok.svg b/nebula_app/assets/icons/nebula-agent-grok.svg new file mode 100644 index 0000000..ac7c94c --- /dev/null +++ b/nebula_app/assets/icons/nebula-agent-grok.svg @@ -0,0 +1 @@ +Grok diff --git a/nebula_app/assets/icons/nebula-agent-openai.svg b/nebula_app/assets/icons/nebula-agent-openai.svg new file mode 100644 index 0000000..8c3e68d --- /dev/null +++ b/nebula_app/assets/icons/nebula-agent-openai.svg @@ -0,0 +1 @@ +OpenAI diff --git a/nebula_app/assets/icons/nebula-agent-opencode.svg b/nebula_app/assets/icons/nebula-agent-opencode.svg new file mode 100644 index 0000000..5c1bb85 --- /dev/null +++ b/nebula_app/assets/icons/nebula-agent-opencode.svg @@ -0,0 +1 @@ +opencode diff --git a/nebula_app/assets/icons/nebula-agent-pi.svg b/nebula_app/assets/icons/nebula-agent-pi.svg new file mode 100644 index 0000000..04e9e04 --- /dev/null +++ b/nebula_app/assets/icons/nebula-agent-pi.svg @@ -0,0 +1 @@ +Pi diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index 3d23a98..c74b422 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -170,8 +170,8 @@ "platform_unsupported": "Automatic hook installation is currently available on Windows.", "checking": "Checking installed agents and hooks…", "applying": "Applying…", - "installed": "Notifications on", - "off": "Notifications off", + "installed": "Hook installed", + "off": "Hook not installed", "not_detected": "Not installed", "config_detected": "Agent configuration detected", "unsupported": "Hook integration not available yet", @@ -182,7 +182,7 @@ "disabled": "Hooks removed. Restart the agent to apply.", "failed": "Could not update hooks: {error}", "helper_missing": "The hook helper is missing. Repair the Pebrel installation to enable hooks.", - "needs_repair": "Installed · needs repair", + "needs_repair": "Hook installed · needs repair", "initialize": "Run this agent once to create its configuration, then refresh.", "config_path": "Configuration: {path}" } diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index b4c7b5c..9bacfd5 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -170,8 +170,8 @@ "platform_unsupported": "目前仅 Windows 支持自动安装 hooks。", "checking": "正在检测工具和 hooks…", "applying": "处理中…", - "installed": "已开启通知", - "off": "未开启通知", + "installed": "Hook 已安装", + "off": "Hook 未安装", "not_detected": "未安装", "config_detected": "已检测到工具配置", "unsupported": "尚未提供 hook 接入", @@ -182,7 +182,7 @@ "disabled": "Hooks 已移除,请重启 agent 使其生效。", "failed": "无法更新 hooks:{error}", "helper_missing": "缺少 hook 辅助程序,请修复 Pebrel 安装后再启用。", - "needs_repair": "已安装 · 需修复", + "needs_repair": "Hook 已安装 · 需修复", "initialize": "请先运行一次此工具以创建配置,然后刷新。", "config_path": "配置:{path}" } diff --git a/nebula_app/i18n/zh-TW.json b/nebula_app/i18n/zh-TW.json index 4123d46..9f3abf3 100644 --- a/nebula_app/i18n/zh-TW.json +++ b/nebula_app/i18n/zh-TW.json @@ -51,8 +51,8 @@ "platform_unsupported": "目前僅 Windows 支援自動安裝 hooks。", "checking": "正在偵測工具與 hooks…", "applying": "處理中…", - "installed": "已開啟通知", - "off": "未開啟通知", + "installed": "Hook 已安裝", + "off": "Hook 未安裝", "not_detected": "未安裝", "config_detected": "已偵測到工具設定", "unsupported": "尚未提供 hook 整合", @@ -63,7 +63,7 @@ "disabled": "Hooks 已移除,請重新啟動 agent 使其生效。", "failed": "無法更新 hooks:{error}", "helper_missing": "缺少 hook 輔助程式,請修復 Pebrel 安裝後再啟用。", - "needs_repair": "已安裝 · 需修復", + "needs_repair": "Hook 已安裝 · 需修復", "initialize": "請先執行一次此工具以建立設定,然後重新整理。", "config_path": "設定:{path}" } diff --git a/nebula_app/src/gpui_shell/assets.rs b/nebula_app/src/gpui_shell/assets.rs index 0e96309..d17e017 100644 --- a/nebula_app/src/gpui_shell/assets.rs +++ b/nebula_app/src/gpui_shell/assets.rs @@ -39,6 +39,16 @@ const NEBULA_ICONS: &[(&str, &[u8])] = icons![ "vcs-conflict", "agent-cursor", "agent-copilot", + "agent-openai", + "agent-opencode", + "agent-pi", + "agent-grok", +]; + +const AGENT_ICONS: &[(&str, &[u8])] = &[ + ("icons/agent-claude.svg", include_bytes!("../../../extra/logo/ai_claude.svg")), + ("icons/agent-kimi.svg", include_bytes!("../../../extra/logo/ai_kimi.svg")), + ("icons/agent-omp.svg", include_bytes!("../../../extra/logo/ai_omp.svg")), ]; /// 先查本仓库,未命中再交给组件库。 @@ -46,7 +56,9 @@ pub struct NebulaAssets; impl AssetSource for NebulaAssets { fn load(&self, path: &str) -> Result>> { - if let Some((_, bytes)) = NEBULA_ICONS.iter().find(|(name, _)| *name == path) { + if let Some((_, bytes)) = + NEBULA_ICONS.iter().chain(AGENT_ICONS).find(|(name, _)| *name == path) + { return Ok(Some(Cow::Borrowed(bytes))); } gpui_component_assets::Assets.load(path) @@ -57,6 +69,7 @@ impl AssetSource for NebulaAssets { names.extend( NEBULA_ICONS .iter() + .chain(AGENT_ICONS) .filter(|(name, _)| name.starts_with(path)) .map(|(name, _)| SharedString::from(*name)), ); @@ -66,6 +79,13 @@ impl AssetSource for NebulaAssets { /// 自带图标的路径。走路径而不是 `IconName`:扩展那个枚举等于改 fork。 pub mod nav { + pub const AGENT_CLAUDE: &str = "icons/agent-claude.svg"; + pub const AGENT_KIMI: &str = "icons/agent-kimi.svg"; + pub const AGENT_OMP: &str = "icons/agent-omp.svg"; + pub const AGENT_OPENAI: &str = "icons/nebula-agent-openai.svg"; + pub const AGENT_OPENCODE: &str = "icons/nebula-agent-opencode.svg"; + pub const AGENT_PI: &str = "icons/nebula-agent-pi.svg"; + pub const AGENT_GROK: &str = "icons/nebula-agent-grok.svg"; pub const AGENT_CURSOR: &str = "icons/nebula-agent-cursor.svg"; pub const AGENT_COPILOT: &str = "icons/nebula-agent-copilot.svg"; pub const LAYOUT_GRID: &str = "icons/nebula-layout-grid.svg"; diff --git a/nebula_app/src/gpui_shell/file_editor/activity.rs b/nebula_app/src/gpui_shell/file_editor/activity.rs index 510e041..9c68645 100644 --- a/nebula_app/src/gpui_shell/file_editor/activity.rs +++ b/nebula_app/src/gpui_shell/file_editor/activity.rs @@ -69,7 +69,7 @@ mod tests { let path = directory.path().join("activity.md"); let original = format!("# 中文标题\n\n正文\n\n{}", "后面的段落\n\n".repeat(60)); std::fs::write(&path, &original).unwrap(); - let (file, mut cx) = super::super::tests::open(path.clone(), cx); + let (file, mut cx) = super::super::tests::open_live(path.clone(), cx); cx.update(|window, cx| file.update(cx, |view, cx| view.begin_live_edit(1, window, cx))); cx.simulate_input("未保存🌿"); cx.run_until_parked(); diff --git a/nebula_app/src/gpui_shell/file_editor/code_actions.rs b/nebula_app/src/gpui_shell/file_editor/code_actions.rs index 65c5013..b635319 100644 --- a/nebula_app/src/gpui_shell/file_editor/code_actions.rs +++ b/nebula_app/src/gpui_shell/file_editor/code_actions.rs @@ -260,6 +260,7 @@ fn render_language_picker( cx: &mut App, ) -> impl IntoElement { let colors = code_ui_colors(cx); + let current_selector = current.clone(); let picker = div() .id("markdown-language-picker") .debug_selector(|| "markdown-language-picker".to_owned()) @@ -287,7 +288,12 @@ fn render_language_picker( // the latter remains discoverable through focus-visible. .focus_visible(|trigger| trigger.border_1().border_color(colors.accent)) .hover(move |trigger| trigger.bg(colors.hover)) - .child(div().flex_none().child(current)) + .child( + div() + .debug_selector(move || format!("markdown-language-current-{current_selector}")) + .flex_none() + .child(current), + ) .child( div() .w(px(16.0)) @@ -392,11 +398,18 @@ pub(super) fn extensions( pub(super) fn render( owner: gpui::WeakEntity, block: usize, - code: CodeSpec, + mut code: CodeSpec, hover_group: SharedString, window: &mut Window, cx: &mut App, ) -> gpui::AnyElement { + if let Some((start, _)) = code.span + && let Some(language) = owner + .upgrade() + .and_then(|view| view.read(cx).preview_code_languages.get(&(block, start)).cloned()) + { + code.language = Some(language); + } render_with_input(owner, block, code, hover_group, None, window, cx) } @@ -415,7 +428,7 @@ pub(super) fn render_with_input( let current = code .language .clone() - .filter(|value| !matches!(value.as_ref(), "text" | "plaintext")) + .filter(|value| !matches!(value.as_ref(), "" | "text" | "plaintext")) .unwrap_or_else(|| plain.clone()); let current_for_state = current.clone(); let span = code.span; @@ -450,8 +463,13 @@ pub(super) fn render_with_input( .map(|item| item.name.clone()); if let Some(choice) = choice { if let Some((start, end)) = state.span { - let choice = - if choice == state.plain { "" } else { choice.as_ref() }; + let choice = if choice == state.plain + || matches!(choice.as_ref(), "text" | "plaintext") + { + "" + } else { + choice.as_ref() + }; let _ = owner.update(cx, |view, cx| { view.set_preview_language(block, start, end, choice, window, cx) }); diff --git a/nebula_app/src/gpui_shell/file_editor/code_actions_tests.rs b/nebula_app/src/gpui_shell/file_editor/code_actions_tests.rs index bf278a7..24169e3 100644 --- a/nebula_app/src/gpui_shell/file_editor/code_actions_tests.rs +++ b/nebula_app/src/gpui_shell/file_editor/code_actions_tests.rs @@ -17,6 +17,7 @@ fn open(path: PathBuf, cx: &mut TestAppContext) -> (Entity, Visual let mut file = None; let (_, window) = cx.add_window_view(|window, cx| { let view = cx.new(|cx| TextFileView::new(path, window, cx)); + view.update(cx, |view, _| view.live_mode = true); file = Some(view.clone()); Root::new(view, window, cx) }); @@ -36,6 +37,65 @@ fn press(key: &str, cx: &mut VisualTestContext) { cx.simulate_event(gpui::KeyUpEvent { keystroke }); } +#[gpui::test] +fn read_only_language_picker_changes_highlighting_without_editing_source(cx: &mut TestAppContext) { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("reader-language.md"); + let source = format!("```text\nfn main() {{}}\n```\n\n{}", "Trailing paragraph\n\n".repeat(80)); + std::fs::write(&path, &source).unwrap(); + let (file, mut cx) = super::tests::open(path.clone(), cx); + let draw = |cx: &mut VisualTestContext| { + cx.update(|window, cx| { + window.draw(cx).clear(cx); + }); + cx.run_until_parked(); + }; + for choice in ["rust", "plaintext"] { + draw(&mut cx); + let picker = cx.debug_bounds("markdown-language-picker").unwrap(); + cx.simulate_click(picker.center(), Modifiers::default()); + cx.run_until_parked(); + cx.simulate_keystrokes("ctrl-a"); + cx.simulate_input(choice); + cx.run_until_parked(); + press("enter", &mut cx); + cx.run_until_parked(); + draw(&mut cx); + file.read_with(&cx, |view, cx| { + assert!(view.preview && !view.live_mode && view.live_edit.is_none()); + assert_eq!(view.draft(cx).as_ref(), source); + assert!(!view.dirty); + assert_eq!( + view.preview_code_languages.get(&(0, 0)).map(|value| value.as_ref()), + Some(if choice == "plaintext" { "" } else { choice }), + ); + }); + if choice == "rust" { + assert!(cx.debug_bounds("markdown-language-current-rust").is_some()); + // Virtualizing the code block must not discard this document's display choice. + file.update(&mut cx, |view, cx| { + view.scroll.scroll_to_reveal_item(70); + cx.notify(); + }); + draw(&mut cx); + file.update(&mut cx, |view, cx| { + view.scroll.scroll_to_reveal_item(0); + cx.notify(); + }); + draw(&mut cx); + assert!(cx.debug_bounds("markdown-language-current-rust").is_some()); + } + let code = cx.debug_bounds("pebrel-code-block").unwrap(); + cx.simulate_mouse_move(code.center(), None, Modifiers::default()); + draw(&mut cx); + let copy = cx.debug_bounds("markdown-copy-code").unwrap(); + cx.simulate_click(copy.center(), Modifiers::default()); + cx.run_until_parked(); + assert_eq!(cx.read_from_clipboard().unwrap().text().unwrap().trim_end(), "fn main() {}"); + assert_eq!(std::fs::read_to_string(&path).unwrap(), source); + } +} + #[gpui::test] fn picker_trigger_popup_search_and_rows_keep_html_geometry(cx: &mut TestAppContext) { let directory = tempfile::tempdir().unwrap(); diff --git a/nebula_app/src/gpui_shell/file_editor/inline_live_tests.rs b/nebula_app/src/gpui_shell/file_editor/inline_live_tests.rs index c2c2d10..79142b5 100644 --- a/nebula_app/src/gpui_shell/file_editor/inline_live_tests.rs +++ b/nebula_app/src/gpui_shell/file_editor/inline_live_tests.rs @@ -15,7 +15,7 @@ fn open( let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("inline-live.md"); std::fs::write(&path, source).unwrap(); - let (file, window) = tests::open(path, cx); + let (file, window) = tests::open_live(path, cx); (directory, file, window) } diff --git a/nebula_app/src/gpui_shell/file_editor/inline_object_tests.rs b/nebula_app/src/gpui_shell/file_editor/inline_object_tests.rs index 41e80d0..fa0467d 100644 --- a/nebula_app/src/gpui_shell/file_editor/inline_object_tests.rs +++ b/nebula_app/src/gpui_shell/file_editor/inline_object_tests.rs @@ -47,7 +47,7 @@ fn second_identical_formula_edits_only_its_source_and_keeps_other_objects(cx: &m let path = dir.path().join("objects.md"); let source = "**Before** $x^2$ between $x^2$ **After**"; std::fs::write(&path, source).unwrap(); - let (file, mut cx) = tests::open(path.clone(), cx); + let (file, mut cx) = tests::open_live(path.clone(), cx); let first = part_for(&file, "$x^2$", 0, &cx); let second = part_for(&file, "$x^2$", 1, &cx); draw(&mut cx); @@ -95,7 +95,7 @@ fn image_source_is_local_and_surrounding_formula_stays_rendered(cx: &mut TestApp let path = dir.path().join("image.md"); let source = "Before ![alt](image.png) $y^2$ **After**"; std::fs::write(&path, source).unwrap(); - let (file, mut cx) = tests::open(path, cx); + let (file, mut cx) = tests::open_live(path, cx); let image_part = part_for(&file, "![alt](image.png)", 0, &cx); click_part(image_part, &mut cx); file.read_with(&cx, |view, cx| { diff --git a/nebula_app/src/gpui_shell/file_editor/live_commands.rs b/nebula_app/src/gpui_shell/file_editor/live_commands.rs index fc241f4..f4b61ac 100644 --- a/nebula_app/src/gpui_shell/file_editor/live_commands.rs +++ b/nebula_app/src/gpui_shell/file_editor/live_commands.rs @@ -36,7 +36,10 @@ impl TextFileView { window: &mut Window, cx: &mut Context, ) { - if self.loading || self.document.as_ref().is_none_or(|doc| doc.read_only) { + if (self.preview && !self.preview_editable()) + || self.loading + || self.document.as_ref().is_none_or(|doc| doc.read_only) + { return; } self.history.record(&self.input.read(cx).value()); diff --git a/nebula_app/src/gpui_shell/file_editor/live_edit.rs b/nebula_app/src/gpui_shell/file_editor/live_edit.rs index cb1c47b..2e79b01 100644 --- a/nebula_app/src/gpui_shell/file_editor/live_edit.rs +++ b/nebula_app/src/gpui_shell/file_editor/live_edit.rs @@ -60,6 +60,15 @@ pub(super) fn decorations(projection: &Projection, cx: &App) -> Vec bool { + self.live_mode + && self.render_active + && self.preview + && !self.loading + && !self.outline.limited + && self.document.as_ref().is_some_and(|document| !document.read_only) + } + pub(super) fn document_input_focused(&self, window: &Window, cx: &App) -> bool { self.focus.is_focused(window) || (!self.preview && self.input.read(cx).focus_handle(cx).is_focused(window)) @@ -111,13 +120,7 @@ impl TextFileView { return; } } - if !self.live_mode - || !self.render_active - || !self.preview - || self.loading - || self.outline.limited - || self.document.as_ref().is_none_or(|document| document.read_only) - { + if !self.preview_editable() { return; } if self.live_edit.as_ref().is_some_and(|edit| edit.block == block && edit.part == part) { diff --git a/nebula_app/src/gpui_shell/file_editor/live_tests.rs b/nebula_app/src/gpui_shell/file_editor/live_tests.rs index 9ebab84..6327cf5 100644 --- a/nebula_app/src/gpui_shell/file_editor/live_tests.rs +++ b/nebula_app/src/gpui_shell/file_editor/live_tests.rs @@ -10,7 +10,7 @@ fn unchanged_activation_skips_parse_without_losing_an_earlier_pending_edit( let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("activation.md"); std::fs::write(&path, "# Heading\n\n中文段落\n").unwrap(); - let (file, mut cx) = tests::open(path, cx); + let (file, mut cx) = tests::open_live(path, cx); cx.update(|_, cx| file.update(cx, |view, cx| view.finish_live_edit(cx))); cx.run_until_parked(); cx.update(|window, cx| { @@ -55,7 +55,7 @@ fn formatted_edit_saves_source_without_rewriting_other_blocks(cx: &mut TestAppCo let path = directory.path().join("inline.md"); let original = "## 中文 **标题** ##\n\n[link](https://example.test \"title\")\n\n~~~rust\nlet n = 1;\n~~~\n"; std::fs::write(&path, original).unwrap(); - let (file, mut cx) = tests::open(path.clone(), cx); + let (file, mut cx) = tests::open_live(path.clone(), cx); let input = cx.update(|window, cx| { file.update(cx, |view, cx| { view.live_mode = true; @@ -83,7 +83,7 @@ fn undo_redo_crosses_formatted_source_and_finished_blocks(cx: &mut TestAppContex let path = directory.path().join("history.md"); let original = "# Title\n\n**Body**\n"; std::fs::write(&path, original).unwrap(); - let (file, mut cx) = tests::open(path, cx); + let (file, mut cx) = tests::open_live(path, cx); cx.update(|window, cx| { file.update(cx, |view, cx| { view.live_mode = true; @@ -113,7 +113,7 @@ fn composition_keeps_native_input_identity_and_marked_selection(cx: &mut TestApp let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("ime.md"); std::fs::write(&path, "**你好**\n").unwrap(); - let (file, mut cx) = tests::open(path, cx); + let (file, mut cx) = tests::open_live(path, cx); let input = cx.update(|window, cx| { file.update(cx, |view, cx| { view.live_mode = true; @@ -140,7 +140,7 @@ fn composition_before_the_first_layout_keeps_its_caret(cx: &mut TestAppContext) let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("first-frame.md"); std::fs::write(&path, "你好").unwrap(); - let (file, mut cx) = tests::open(path, cx); + let (file, mut cx) = tests::open_live(path, cx); let input = cx.update(|window, cx| { let input = file.update(cx, |view, cx| { let click = gpui::ClickEvent::Mouse(gpui::MouseClickEvent { @@ -167,7 +167,7 @@ fn enter_creates_an_editable_paragraph_and_undo_restores_the_heading(cx: &mut Te let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("paragraph.md"); std::fs::write(&path, "# Title").unwrap(); - let (file, mut cx) = tests::open(path, cx); + let (file, mut cx) = tests::open_live(path, cx); cx.update(|window, cx| { file.update(cx, |view, cx| { view.live_mode = true; @@ -195,7 +195,7 @@ fn clicking_near_the_start_places_the_caret_near_the_start(cx: &mut TestAppConte let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("caret.md"); std::fs::write(&path, "A paragraph with a long tail.").unwrap(); - let (file, mut cx) = tests::open(path, cx); + let (file, mut cx) = tests::open_live(path, cx); cx.update(|window, cx| { file.update(cx, |view, cx| { view.live_mode = true; @@ -224,7 +224,7 @@ fn first_click_uses_painted_glyphs_after_the_frame_callback(cx: &mut TestAppCont let path = directory.path().join("first-click.md"); let source = "中文🌿 mixed text with a long tail for positioning."; std::fs::write(&path, source).unwrap(); - let (file, mut cx) = tests::open(path, cx); + let (file, mut cx) = tests::open_live(path, cx); cx.update(|window, cx| { let _ = window.draw(cx); }); @@ -264,7 +264,7 @@ fn activating_heading_preserves_following_paragraph_position(cx: &mut TestAppCon let path = directory.path().join(format!("heading-{scale}-{level}.md")); std::fs::write(&path, format!("{} A heading\n\nFollowing paragraph", "#".repeat(level))) .unwrap(); - let (file, mut visual) = tests::open(path, cx); + let (file, mut visual) = tests::open_live(path, cx); visual.update(|window, cx| { window.set_scale_factor(scale); file.update(cx, |_, cx| cx.notify()); diff --git a/nebula_app/src/gpui_shell/file_editor/mod.rs b/nebula_app/src/gpui_shell/file_editor/mod.rs index 4ff99fe..c3e28af 100644 --- a/nebula_app/src/gpui_shell/file_editor/mod.rs +++ b/nebula_app/src/gpui_shell/file_editor/mod.rs @@ -128,6 +128,8 @@ pub struct TextFileView { inline_views: Rc>>>, preview_extensions: gpui_component::text::MarkdownExtensions, + /// Reading-only syntax choices survive virtual row eviction, but never change source. + preview_code_languages: std::collections::HashMap<(usize, usize), SharedString>, preview_images: Entity, scroll: ListState, preview_selection_scroll_epoch: u64, @@ -229,6 +231,7 @@ impl TextFileView { let mut this = Self { preview_images: image_cache::DocumentImageCache::new(cx.entity_id(), cx), preview_extensions, + preview_code_languages: Default::default(), path, title, input, @@ -243,7 +246,8 @@ impl TextFileView { notice: None, markdown, preview: markdown, - live_mode: markdown, + // Rendered Markdown opens as a selectable reader. Editing is explicit via source mode. + live_mode: false, live_edit: None, render_active: true, preview_stale: false, @@ -550,6 +554,7 @@ impl TextFileView { fn apply_outline(&mut self, outline: Outline, cx: &mut Context) { self.preview_stale = false; + self.preview_code_languages.clear(); self.inline_views.borrow_mut().clear(); let top = self.scroll.logical_scroll_top(); self.blocks = Rc::new(RefCell::new(vec![None; outline.blocks.len()])); diff --git a/nebula_app/src/gpui_shell/file_editor/preview.rs b/nebula_app/src/gpui_shell/file_editor/preview.rs index 4b14673..b6a355b 100644 --- a/nebula_app/src/gpui_shell/file_editor/preview.rs +++ b/nebula_app/src/gpui_shell/file_editor/preview.rs @@ -515,9 +515,17 @@ impl TextFileView { window: &mut Window, cx: &mut Context, ) { + let key = (block, start); let Some((start, end)) = self.outline.source_span(block, start, end) else { return }; let Some(source) = self.outline.blocks.get(block) else { return }; let Some(next) = fence_language(source, start, end, language) else { return }; + if !self.preview_editable() { + if self.preview && self.render_active && !self.loading { + self.preview_code_languages.insert(key, language.to_owned().into()); + cx.notify(); + } + return; + } let Some(range) = self.outline.source_ranges.get(block).cloned() else { return }; self.commit_structure_edit(range, &next, None, window, cx); } diff --git a/nebula_app/src/gpui_shell/file_editor/structure_commands.rs b/nebula_app/src/gpui_shell/file_editor/structure_commands.rs index f4209c2..5423d1c 100644 --- a/nebula_app/src/gpui_shell/file_editor/structure_commands.rs +++ b/nebula_app/src/gpui_shell/file_editor/structure_commands.rs @@ -30,7 +30,7 @@ impl TextFileView { window: &mut Window, cx: &mut Context, ) { - if self.loading || self.document.as_ref().is_none_or(|document| document.read_only) { + if !self.preview_editable() { return; } self.update_live_edit(window, cx); diff --git a/nebula_app/src/gpui_shell/file_editor/structure_tests.rs b/nebula_app/src/gpui_shell/file_editor/structure_tests.rs index fbdae1c..47b1516 100644 --- a/nebula_app/src/gpui_shell/file_editor/structure_tests.rs +++ b/nebula_app/src/gpui_shell/file_editor/structure_tests.rs @@ -11,7 +11,7 @@ fn open( let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("structured.md"); std::fs::write(&path, source).unwrap(); - let (view, window) = tests::open(path, cx); + let (view, window) = tests::open_live(path, cx); (directory, view, window) } diff --git a/nebula_app/src/gpui_shell/file_editor/structure_view.rs b/nebula_app/src/gpui_shell/file_editor/structure_view.rs index b62f2ee..cdbf79e 100644 --- a/nebula_app/src/gpui_shell/file_editor/structure_view.rs +++ b/nebula_app/src/gpui_shell/file_editor/structure_view.rs @@ -68,7 +68,11 @@ impl TextFileView { let input = live.then(|| self.render_live_input(cx)); let spec = super::code_actions::CodeSpec { source: source[structure.parts[*part].range.clone()].to_owned().into(), - language: (!language.is_empty()).then(|| language.clone().into()), + language: self + .preview_code_languages + .get(&(block, span.start)) + .cloned() + .or_else(|| (!language.is_empty()).then(|| language.clone().into())), span: Some((span.start, span.end)), }; let part = *part; @@ -153,6 +157,7 @@ impl TextFileView { let check = check.clone(); Button::new(("markdown-task", item.range.start)) .ghost() + .disabled(!self.preview_editable()) .size(px(24.0)) .p_0() .flex_shrink_0() diff --git a/nebula_app/src/gpui_shell/file_editor/tests.rs b/nebula_app/src/gpui_shell/file_editor/tests.rs index 05191c0..58906a7 100644 --- a/nebula_app/src/gpui_shell/file_editor/tests.rs +++ b/nebula_app/src/gpui_shell/file_editor/tests.rs @@ -5,6 +5,22 @@ use gpui_component::Root; pub(super) fn open( path: PathBuf, cx: &mut TestAppContext, +) -> (Entity, VisualTestContext) { + open_with_mode(path, false, cx) +} + +/// Existing live-editor regressions opt in explicitly; the product opens in reader mode. +pub(super) fn open_live( + path: PathBuf, + cx: &mut TestAppContext, +) -> (Entity, VisualTestContext) { + open_with_mode(path, true, cx) +} + +fn open_with_mode( + path: PathBuf, + live_mode: bool, + cx: &mut TestAppContext, ) -> (Entity, VisualTestContext) { cx.update(|cx| { gpui_component::init(cx); @@ -13,7 +29,13 @@ pub(super) fn open( }); let mut file = None; let (_, window) = cx.add_window_view(|window, cx| { - let view = cx.new(|cx| TextFileView::new(path, window, cx)); + let view = cx.new(|cx| { + let mut view = TextFileView::new(path, window, cx); + if live_mode { + view.live_mode = true; + } + view + }); file = Some(view.clone()); Root::new(view, window, cx) }); @@ -21,6 +43,81 @@ pub(super) fn open( (file.unwrap(), window.clone()) } +#[gpui::test] +fn default_reader_blocks_edits_but_keeps_selection_and_code_copy(cx: &mut TestAppContext) { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("reader.md"); + let source = "# Title\n\nText to select\n\n- [ ] Task\n\n```rust\nlet x = 42;\n```\n"; + std::fs::write(&path, source).unwrap(); + let (file, mut cx) = open(path.clone(), cx); + cx.simulate_resize(gpui::size(px(1100.0), px(1000.0))); + for selector in ["markdown-preview-block-1", "markdown-task-box-2", "pebrel-code-text"] { + cx.update(|window, cx| { + window.draw(cx).clear(cx); + }); + let bounds = cx.debug_bounds(selector).expect(selector); + cx.simulate_click(bounds.center(), gpui::Modifiers::default()); + cx.simulate_input("must not edit"); + cx.simulate_keystrokes("backspace"); + cx.run_until_parked(); + file.read_with(&cx, |view, cx| { + assert!(view.preview && !view.live_mode && view.live_edit.is_none()); + assert_eq!(view.draft(cx), source); + assert!(!view.dirty); + }); + } + assert!(cx.debug_bounds("markdown-language-picker").is_some()); + // Read-only applies to commands too, not just the visible controls. + cx.update(|window, cx| { + file.update(cx, |view, cx| { + view.commit_structure_edit(0..1, "changed", None, window, cx); + view.focus.focus(window, cx); + }) + }); + cx.simulate_keystrokes("ctrl-a ctrl-c"); + cx.run_until_parked(); + assert_eq!(cx.read_from_clipboard().unwrap().text().unwrap(), source); + cx.update(|window, cx| { + window.draw(cx).clear(cx); + }); + let code = cx.debug_bounds("pebrel-code-block").unwrap(); + cx.simulate_mouse_move(code.center(), None, gpui::Modifiers::default()); + cx.update(|window, cx| { + window.draw(cx).clear(cx); + }); + let copy = cx.debug_bounds("markdown-copy-code").unwrap(); + cx.simulate_click(copy.center(), gpui::Modifiers::default()); + cx.run_until_parked(); + assert_eq!(cx.read_from_clipboard().unwrap().text().unwrap().trim_end(), "let x = 42;"); + assert!(!file.read_with(&cx, |view, _| view.dirty)); + assert_eq!(std::fs::read_to_string(path).unwrap(), source); +} + +#[gpui::test] +fn returning_to_reader_keeps_source_edits_and_blocks_undo_until_source_is_open( + cx: &mut TestAppContext, +) { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("source-edit.md"); + let source = "Original"; + std::fs::write(&path, source).unwrap(); + let (file, mut cx) = open(path, cx); + cx.update(|window, cx| file.read(cx).focus.clone().focus(window, cx)); + cx.simulate_keystrokes("ctrl-/ ctrl-a"); + cx.simulate_input("Updated"); + cx.run_until_parked(); + cx.simulate_keystrokes("ctrl-/ ctrl-z"); + cx.run_until_parked(); + file.read_with(&cx, |view, cx| { + assert!(view.preview && !view.live_mode); + assert_eq!(view.draft(cx), "Updated"); + assert!(view.dirty); + }); + cx.simulate_keystrokes("ctrl-/ ctrl-z"); + cx.run_until_parked(); + assert_eq!(file.read_with(&cx, |view, cx| view.draft(cx)), source); +} + #[gpui::test] fn source_mode_releases_preview_views_without_changing_the_document(cx: &mut TestAppContext) { let directory = tempfile::tempdir().unwrap(); @@ -110,7 +207,7 @@ fn outline_arrow_folds_without_navigating_or_rewriting_the_document(cx: &mut Tes } #[gpui::test] -fn document_opens_editable_and_source_shortcut_preserves_draft_and_details( +fn document_opens_read_only_and_source_shortcut_preserves_draft_and_details( cx: &mut TestAppContext, ) { let directory = tempfile::tempdir().unwrap(); @@ -118,7 +215,7 @@ fn document_opens_editable_and_source_shortcut_preserves_draft_and_details( let source = "# Title\n\nOriginal text\n"; std::fs::write(&path, source).unwrap(); let (file, mut cx) = open(path, cx); - assert!(file.read_with(&cx, |view, _| view.preview && view.live_mode)); + assert!(file.read_with(&cx, |view, _| view.preview && !view.live_mode)); cx.update(|window, cx| file.read(cx).focus.clone().focus(window, cx)); for preview in [false, true, false, true] { cx.update(|window, cx| { @@ -166,7 +263,7 @@ fn code_actions_copy_raw_source_and_search_languages(cx: &mut TestAppContext) { let path = directory.path().join("code.md"); let source = "```text\nlet x = 42;\n```\n"; std::fs::write(&path, source).unwrap(); - let (file, mut cx) = open(path, cx); + let (file, mut cx) = open_live(path, cx); cx.update(|window, cx| { let _ = window.draw(cx); }); diff --git a/nebula_app/src/gpui_shell/settings_pane.rs b/nebula_app/src/gpui_shell/settings_pane.rs index 3f49e78..93ce15d 100644 --- a/nebula_app/src/gpui_shell/settings_pane.rs +++ b/nebula_app/src/gpui_shell/settings_pane.rs @@ -1536,7 +1536,15 @@ impl Render for SettingsPane { .w_full() .flex() .justify_center() - .child(v_flex().w_full().when(application_page, |content| content.max_w(px(960.0))).child(content)), + .child( + v_flex() + .w_full() + .when(matches!(self.active_section, 9 | 10), |content| { + content.items_center() + }) + .when(application_page, |content| content.max_w(px(960.0))) + .child(content), + ), ), ), ) diff --git a/nebula_app/src/gpui_shell/settings_pane/agents.rs b/nebula_app/src/gpui_shell/settings_pane/agents.rs index f2fe649..8f573a7 100644 --- a/nebula_app/src/gpui_shell/settings_pane/agents.rs +++ b/nebula_app/src/gpui_shell/settings_pane/agents.rs @@ -2,9 +2,6 @@ use super::*; use crate::ai_agents::AgentKind; use crate::ai_hook::integrations::{self, AgentIntegration}; use crate::i18n::Message; -use std::collections::HashMap; - -type AgentLogos = HashMap<(crate::display::AiLogo, bool), Arc>; #[cfg(all(test, feature = "gpui-test-support"))] mod tests; @@ -27,7 +24,6 @@ pub(super) struct AgentSettingsState { sequence: u64, feedback: Option<(AgentKind, Result)>, focus: Vec, - logos: AgentLogos, #[cfg(all(test, feature = "gpui-test-support"))] test_operation: Option, } @@ -41,7 +37,6 @@ impl AgentSettingsState { sequence: 0, feedback: None, focus: integrations::AGENTS.iter().map(|_| cx.focus_handle().tab_stop(true)).collect(), - logos: HashMap::new(), #[cfg(all(test, feature = "gpui-test-support"))] test_operation: None, } @@ -49,11 +44,6 @@ impl AgentSettingsState { } impl SettingsPane { - pub(in crate::gpui_shell) fn set_agent_logos(&mut self, logos: &AgentLogos) { - // 直接共享侧边栏已经缩放好的纹理,打开设置不再解码原图或另建图片缓存。 - self.agents.logos.clone_from(logos); - } - fn refresh_agents(&mut self, cx: &mut Context) { if self.agents.loading || self.agents.busy.is_some() { return; @@ -237,6 +227,12 @@ impl SettingsPane { let hover = crate::gpui_shell::theme::settings_hover_bg(cx, false); let border = crate::gpui_shell::theme::settings_hairline(cx); let ring = cx.theme().ring; + let executable = row + .executable + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| language.text(Message::SettingsAgentsNotDetected).to_owned()); + let path_tooltip = executable.clone(); let text = v_flex() .flex_1() .min_w_0() @@ -244,24 +240,49 @@ impl SettingsPane { .child( div().text_sm().font_weight(gpui::FontWeight::MEDIUM).child(agent.display_name()), ) - .child(div().text_xs().text_color(muted).child(language.text(status))) + .child( + div() + .id(("agent-cli-path", index)) + .debug_selector(move || format!("agent-cli-path-{index}")) + .w_full() + .truncate() + .text_xs() + .text_color(muted) + .tooltip(move |window, cx| { + gpui_component::tooltip::Tooltip::new(path_tooltip.clone()) + .build(window, cx) + }) + .child(executable), + ) .when_some(note, |view, note| { view.child(div().text_xs().text_color(muted).child(note)) }); + use crate::gpui_shell::assets::nav; let icon = if let Some(path) = match agent { - AgentKind::Cursor => Some(crate::gpui_shell::assets::nav::AGENT_CURSOR), - AgentKind::Copilot => Some(crate::gpui_shell::assets::nav::AGENT_COPILOT), + AgentKind::Claude => Some(nav::AGENT_CLAUDE), + AgentKind::Codex => Some(nav::AGENT_OPENAI), + AgentKind::OpenCode => Some(nav::AGENT_OPENCODE), + AgentKind::Cursor => Some(nav::AGENT_CURSOR), + AgentKind::Kimi => Some(nav::AGENT_KIMI), + AgentKind::Pi => Some(nav::AGENT_PI), + AgentKind::OhMyPi => Some(nav::AGENT_OMP), + AgentKind::Copilot => Some(nav::AGENT_COPILOT), + AgentKind::Grok => Some(nav::AGENT_GROK), _ => None, } { - Icon::default().path(path).size(px(16.0)).into_any_element() - } else if let Some(image) = crate::display::ai_logo_for_program(agent.slug()) - .and_then(|logo| self.agents.logos.get(&(logo, cx.theme().is_dark()))) - { - img(image.clone()).size(px(16.0)).into_any_element() + Icon::default() + .path(path) + .size(px(24.0)) + .text_color(match agent { + AgentKind::Claude => rgb_hsla(217, 119, 87), + AgentKind::OhMyPi => rgb_hsla(147, 98, 239), + _ => cx.theme().foreground, + }) + .into_any_element() } else { div() .font_family(crate::font_install::REQUIRED_FONT_FAMILY) - .text_size(px(16.0)) + .text_size(px(24.0)) .child(crate::display::program_icon(agent.slug())) .into_any_element() }; @@ -307,7 +328,7 @@ impl SettingsPane { })) .child( div() - .size(px(28.0)) + .size(px(32.0)) .flex_shrink_0() .flex() .items_center() @@ -316,17 +337,34 @@ impl SettingsPane { ) .child(text) .child( - div().min_w(px(40.0)).min_h(px(32.0)).flex().items_center().justify_center().child( - crate::gpui_shell::widgets::NebulaSwitch::new(format!( - "agent-{}", - agent.slug() - )) - .checked(checked) - .disabled(disabled) - .on_click(cx.listener(move |this, enabled, _, cx| { - this.toggle_agent_hook(agent, *enabled, cx) - })), - ), + div() + .id(("agent-hook-status", index)) + .debug_selector(move || format!("agent-hook-status-{index}")) + .flex_shrink_0() + .max_w(px(160.0)) + .text_xs() + .text_color(muted) + .child(language.text(status)), + ) + .child( + div() + .flex_shrink_0() + .min_w(px(40.0)) + .min_h(px(32.0)) + .flex() + .items_center() + .justify_center() + .child( + crate::gpui_shell::widgets::NebulaSwitch::new(format!( + "agent-{}", + agent.slug() + )) + .checked(checked) + .disabled(disabled) + .on_click(cx.listener( + move |this, enabled, _, cx| this.toggle_agent_hook(agent, *enabled, cx), + )), + ), ) } } @@ -344,8 +382,6 @@ fn agent_status(row: &AgentIntegration, busy: bool) -> Message { let state = &row.inspection; if busy { Message::SettingsAgentsApplying - } else if row.executable.is_none() { - Message::SettingsAgentsNotDetected } else if state.installed && state.needs_repair { Message::SettingsAgentsNeedsRepair } else if state.installed { diff --git a/nebula_app/src/gpui_shell/settings_pane/agents/tests.rs b/nebula_app/src/gpui_shell/settings_pane/agents/tests.rs index c2dc578..051facc 100644 --- a/nebula_app/src/gpui_shell/settings_pane/agents/tests.rs +++ b/nebula_app/src/gpui_shell/settings_pane/agents/tests.rs @@ -62,6 +62,36 @@ fn fixture( (pane.unwrap(), window, sender, calls) } +#[gpui::test] +fn agents_content_is_centered_and_paths_leave_room_for_hook_status(cx: &mut TestAppContext) { + let (pane, window, reply, _) = fixture(cx); + drop(reply); + pane.update(window, |pane, cx| { + pane.agents.rows.as_mut().unwrap()[0].executable = Some( + "C:/Users/example/AppData/Local/a-very-long-installation-directory/bin/claude.exe" + .into(), + ); + cx.notify(); + }); + for width in [1440.0, 800.0] { + window.simulate_resize(size(px(width), px(900.0))); + window.run_until_parked(); + window.update(|window, cx| { + window.refresh(); + window.draw(cx).clear(cx); + }); + let row = window.debug_bounds("agent-hook-row-0").unwrap(); + let path = window.debug_bounds("agent-cli-path-0").unwrap(); + let status = window.debug_bounds("agent-hook-status-0").unwrap(); + let center = px((width + SETTINGS_NAV_WIDTH) / 2.0); + assert!((f32::from(row.center().x - center)).abs() <= 2.0); + assert!(row.size.width <= px(720.0)); + assert!(path.size.width > px(0.0)); + assert!(path.right() <= status.left()); + assert!(status.right() + px(40.0) <= row.right()); + } +} + #[gpui::test] fn installed_hook_row_accepts_padding_clicks_and_reports_write_failure(cx: &mut TestAppContext) { if !crate::platform::CAPABILITIES.ai_hook_server { @@ -118,8 +148,14 @@ fn configuration_without_an_executable_is_not_an_installed_agent(cx: &mut TestAp let mut rows = rows(false); rows[0].executable = None; assert!(rows[0].inspection.available); - assert!(matches!(agent_status(&rows[0], false), Message::SettingsAgentsNotDetected)); + assert!(matches!( + agent_status(&rows[0], false), + Message::SettingsAgentsOff | Message::SettingsAgentsUnavailable + )); assert!(!can_toggle(&rows[0])); + rows[0].inspection.installed = true; + assert!(matches!(agent_status(&rows[0], false), Message::SettingsAgentsInstalled)); + rows[0].inspection.installed = false; pane.agents.rows = Some(rows); cx.notify(); }); diff --git a/nebula_app/src/gpui_shell/settings_pane/backup/tests.rs b/nebula_app/src/gpui_shell/settings_pane/backup/tests.rs index 49ee301..fab6856 100644 --- a/nebula_app/src/gpui_shell/settings_pane/backup/tests.rs +++ b/nebula_app/src/gpui_shell/settings_pane/backup/tests.rs @@ -32,6 +32,8 @@ fn pairing_design_backup_groups_scope_and_save_feedback_with_storage( let card = cx.debug_bounds("cloud-storage-card").expect("storage configuration"); assert!(card.right() <= px(width)); assert!(card.size.width <= px(720.0)); + let center = px((width + SETTINGS_NAV_WIDTH) / 2.0); + assert!((f32::from(card.center().x - center)).abs() <= 2.0); let provider = cx.debug_bounds("cloud-provider").expect("bounded provider dropdown"); assert!(provider.size.width <= px(220.0)); assert!(provider.left() >= card.left() && provider.right() <= card.right()); diff --git a/nebula_app/src/gpui_shell/settings_pane/navigation.rs b/nebula_app/src/gpui_shell/settings_pane/navigation.rs index 5736490..fc4f81a 100644 --- a/nebula_app/src/gpui_shell/settings_pane/navigation.rs +++ b/nebula_app/src/gpui_shell/settings_pane/navigation.rs @@ -7,7 +7,7 @@ pub(super) const REPOSITORY_URL: &str = "https://github.com/Kuddev/pebrel"; pub(super) const BUG_REPORT_TEMPLATE: &str = "bug_report.yml"; /// 左侧分区的稳定路由表。2026-08-28 产品裁定:默认 GPUI 导航收敛为常用项, -/// 暂时隐藏“AI 供应商”;页面实现与索引继续保留。后续恢复入口时只改 +/// 暂时隐藏“AI 供应商”和“备份”;页面实现与索引继续保留。后续恢复入口时只改 /// [`HIDDEN_NAV_SECTIONS`],不得删除或重排这里的条目。 pub(super) const SECTION_IDS: [&str; 11] = [ "application", @@ -40,7 +40,7 @@ pub(super) const SECTION_SEARCH_TERMS: [&str; 11] = [ "agents agent hook hooks 智能体 钩子 接入 claude codex opencode cursor kimi pi omp copilot grok", ]; -pub(super) const HIDDEN_NAV_SECTIONS: &[usize] = &[3]; +pub(super) const HIDDEN_NAV_SECTIONS: &[usize] = &[3, 9]; /// 保留原来的分组展开顺序,组名不再渲染;数组里仍保存稳定的 [`SECTION_IDS`] /// 下标,不复制设置状态或路由。 diff --git a/nebula_app/src/gpui_shell/settings_pane/search_header.rs b/nebula_app/src/gpui_shell/settings_pane/search_header.rs index 1d11e3e..b024101 100644 --- a/nebula_app/src/gpui_shell/settings_pane/search_header.rs +++ b/nebula_app/src/gpui_shell/settings_pane/search_header.rs @@ -186,9 +186,9 @@ mod tests { assert_eq!(matching_sections(" FONT ", en), vec![1]); assert_eq!(matching_sections("字体", zh), vec![1]); assert_eq!(matching_sections("quick terminal", en), vec![7]); - assert_eq!(matching_sections("clod backup", en), vec![9]); - assert_eq!(matching_sections("webdav", en), vec![9]); - assert_eq!(matching_sections("123", zh), vec![9]); + assert!(matching_sections("clod backup", en).is_empty()); + assert!(matching_sections("webdav", en).is_empty()); + assert!(matching_sections("123", zh).is_empty()); assert_eq!(matching_sections("透度", zh).first(), Some(&1)); assert!(matching_sections("no such setting", en).is_empty()); assert_eq!(matching_sections("", en), visible_nav_sections().collect::>()); diff --git a/nebula_app/src/gpui_shell/settings_pane/tests.rs b/nebula_app/src/gpui_shell/settings_pane/tests.rs index 19d505a..bdbe0a4 100644 --- a/nebula_app/src/gpui_shell/settings_pane/tests.rs +++ b/nebula_app/src/gpui_shell/settings_pane/tests.rs @@ -82,9 +82,12 @@ fn ai_toast_setting_is_searchable_and_has_a_visible_switch(cx: &mut gpui::TestAp } #[test] -fn settings_nav_visibility_keeps_stable_routes_and_restores_backup() { +fn settings_nav_visibility_keeps_stable_routes_and_hides_backup() { let visibility: Vec<_> = (0..SECTION_IDS.len()).map(is_nav_section_visible).collect(); - assert_eq!(visibility, vec![true, true, true, false, true, true, true, true, true, true, true]); + assert_eq!( + visibility, + vec![true, true, true, false, true, true, true, true, true, false, true] + ); assert_eq!( SECTION_IDS, [ @@ -106,14 +109,14 @@ fn settings_nav_visibility_keeps_stable_routes_and_restores_backup() { #[test] fn settings_nav_starts_with_application_then_frequent_options() { let visible: Vec<_> = visible_nav_sections().collect(); - assert_eq!(visible, vec![0, 1, 2, 10, 6, 7, 4, 5, 8, 9]); + assert_eq!(visible, vec![0, 1, 2, 10, 6, 7, 4, 5, 8]); let zh_labels: Vec<_> = visible .iter() .map(|index| section_label(*index, crate::display::UiLanguage::ZhCn)) .collect(); assert_eq!( zh_labels, - vec!["应用", "外观", "终端", "Agents", "交互", "按键映射", "SSH", "网络", "高级", "备份"] + vec!["应用", "外观", "终端", "Agents", "交互", "按键映射", "SSH", "网络", "高级"] ); let en_labels: Vec<_> = visible .iter() @@ -131,7 +134,6 @@ fn settings_nav_starts_with_application_then_frequent_options() { "SSH", "Network", "Advanced", - "Backup", ] ); } diff --git a/nebula_app/src/gpui_shell/ssh_settings/library.rs b/nebula_app/src/gpui_shell/ssh_settings/library.rs index ab37feb..28072c6 100644 --- a/nebula_app/src/gpui_shell/ssh_settings/library.rs +++ b/nebula_app/src/gpui_shell/ssh_settings/library.rs @@ -13,6 +13,54 @@ mod tests; const HOST_CARD_HEIGHT: f32 = 68.0; const HOST_CARD_EXTENT: f32 = HOST_CARD_HEIGHT + 8.0; +/// Center the glyph's ink, not its monospace advance or the surrounding text line. +fn host_icon(glyph: char, family: SharedString, color: gpui::Hsla) -> impl IntoElement { + gpui::canvas( + move |bounds, window, _| { + let font = gpui::font(family); + let text_system = window.text_system(); + let font_id = text_system.resolve_font(&font); + let base_size = px(18.0); + let ink = text_system.typographic_bounds(font_id, base_size, glyph).ok(); + let scale = ink + .filter(|ink| ink.size.width > px(0.0) && ink.size.height > px(0.0)) + .map(|ink| 18.0 / f32::from(ink.size.width.max(ink.size.height))) + .unwrap_or(1.0); + let font_size = base_size * scale; + let text: SharedString = glyph.to_string().into(); + let line = text_system.shape_line( + text.clone(), + font_size, + &[gpui::TextRun { + len: text.len(), + font, + color, + background_color: None, + underline: None, + strikethrough: None, + }], + None, + ); + let height = line.ascent + line.descent; + let origin = if let Some(ink) = ink { + // Font coordinates are relative to the baseline with positive Y upwards. + let center = ink.center() * scale; + bounds.center() - gpui::point(center.x, line.ascent - center.y) + } else { + bounds.center() - gpui::point(line.width / 2.0, height / 2.0) + }; + (line, origin, height) + }, + |_, (line, origin, height), window, cx| { + if let Err(error) = line.paint(origin, height, gpui::TextAlign::Left, None, window, cx) + { + log::warn!("Unable to paint SSH host icon: {error}"); + } + }, + ) + .size(px(22.0)) +} + #[derive(Clone, Copy, PartialEq, Eq)] pub(in crate::gpui_shell) enum HostScope { All, @@ -399,11 +447,7 @@ impl SettingsPane { crate::gpui_shell::widgets::device_icon_container(cx) .id(SharedString::from(format!("ssh-host-icon-{ix}"))) .debug_selector(move || format!("ssh-host-icon-{ix}")) - .font_family(symbol_family.clone()) - .text_size(px(18.0)) - .text_color(muted) - .text_center() - .child(os_icon.glyph.to_string()), + .child(host_icon(os_icon.glyph, symbol_family, muted)), ) .child( v_flex() diff --git a/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs b/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs index dba0670..53b87a1 100644 --- a/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs +++ b/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs @@ -46,7 +46,10 @@ fn pairing_design_ssh_cards_keep_icon_anchors_and_compact_filter(cx: &mut gpui:: let icon = cx.debug_bounds("ssh-host-icon-0").expect("card anchor"); assert_eq!(icon.size, gpui::size(px(36.0), px(36.0))); let first = cx.debug_bounds("ssh-host-row-0").unwrap(); + assert_eq!(icon.center().y, first.center().y); let next = cx.debug_bounds("ssh-host-row-1").unwrap(); + let next_icon = cx.debug_bounds("ssh-host-icon-1").unwrap(); + assert_eq!(icon.center().x, next_icon.center().x); assert!(next.origin.y - first.bottom() >= px(8.0)); let filter = cx.debug_bounds("ssh-inline-filter").unwrap(); assert!(filter.size.width <= px(210.0)); diff --git a/nebula_app/src/gpui_shell/workspace.rs b/nebula_app/src/gpui_shell/workspace.rs index 241c375..571289d 100644 --- a/nebula_app/src/gpui_shell/workspace.rs +++ b/nebula_app/src/gpui_shell/workspace.rs @@ -3114,7 +3114,6 @@ impl Render for NebulaWorkspace { // 变化时重建,普通 render 不重复解码 PNG。 self.sidebar_logo_images = sidebar_logo_images(sidebar_logo_target_px); self.sidebar_logo_target_px = sidebar_logo_target_px; - self.sync_settings_agent_logos(cx); } // Some tab-open/restore paths assign `active` directly. Clear a focus // record tied to a different entity before deriving layout booleans. diff --git a/nebula_app/src/gpui_shell/workspace/settings_navigation.rs b/nebula_app/src/gpui_shell/workspace/settings_navigation.rs index 8436707..4761f45 100644 --- a/nebula_app/src/gpui_shell/workspace/settings_navigation.rs +++ b/nebula_app/src/gpui_shell/workspace/settings_navigation.rs @@ -36,7 +36,6 @@ impl NebulaWorkspace { let view = cx.new(|cx| SettingsPane::new(window, cx)); let subscription = cx.subscribe_in(&view, window, Self::on_settings_event); self.settings_surface = Some((view, subscription)); - self.sync_settings_agent_logos(cx); } self.settings_tab_open = true; @@ -70,15 +69,6 @@ impl NebulaWorkspace { } } - pub(super) fn sync_settings_agent_logos(&self, cx: &mut Context) { - if let Some((view, _)) = self.settings_surface.as_ref() { - view.update(cx, |pane, cx| { - pane.set_agent_logos(&self.sidebar_logo_images); - cx.notify(); - }); - } - } - pub(super) fn toggle_settings(&mut self, window: &mut Window, cx: &mut Context) { if self.settings_open { self.close_settings(window, cx); From 2de347f57a2843753d4ad74170ed0fafba94d261 Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 21 Sep 2026 22:47:29 +0800 Subject: [PATCH 02/20] release: prepare 1.9.0 notes and native Windows ARM64 packages --- .github/workflows/release.yml | 79 ++++++++++- CHANGELOG.md | 126 +++++++++++------- .../2026-09-21-windows-arm64-portable.md | 65 +++++++++ docs/release-notes/v1.9.0.md | 126 +++++++++++------- scripts/package-release.ps1 | 9 +- scripts/preview_release.py | 7 +- scripts/stable_release.py | 2 + scripts/tests/package-release.tests.ps1 | 1 + scripts/tests/test_preview_release.py | 10 ++ scripts/tests/test_stable_release.py | 23 +++- .../windows-package-architecture.tests.ps1 | 42 ++++++ scripts/windows-package-architecture.ps1 | 30 +++++ 12 files changed, 410 insertions(+), 110 deletions(-) create mode 100644 architecture/notes/packaging/2026-09-21-windows-arm64-portable.md create mode 100644 scripts/tests/windows-package-architecture.tests.ps1 create mode 100644 scripts/windows-package-architecture.ps1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 70cf187..9d5ebfe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -543,10 +543,85 @@ jobs: if-no-files-found: warn retention-days: 14 + windows-arm64: + name: Windows ARM64 stable portable package + if: ${{ !inputs.windows_only }} + needs: prepare + runs-on: windows-11-arm + timeout-minutes: 120 + env: + PACKAGE_VERSION: ${{ needs.prepare.outputs.version }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + - name: Require native Windows ARM64 + shell: pwsh + run: | + if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -ne 'Arm64') { + throw 'ARM64 packaging requires an ARM64 host.' + } + $compiler = rustc -vV + if ($LASTEXITCODE -ne 0 -or $compiler -notcontains 'host: aarch64-pc-windows-msvc') { + throw 'ARM64 packaging requires a native ARM64 compiler.' + } + - uses: ./.github/actions/rust-cache + id: rust-cache + with: + key: release + target: target/release + - name: Prepare and verify pinned ARM64 console runtime + shell: pwsh + run: | + ./scripts/prepare-windows-runtime.ps1 -Destination assets/windows/conhost -Architecture arm64 + ./scripts/tests/windows-runtime.tests.ps1 -Architecture arm64 + ./scripts/tests/windows-package-architecture.tests.ps1 + - name: Build native ARM64 application and hook + shell: pwsh + run: ./scripts/build-windows-product.ps1 -Configuration release -TargetDirectory target + - name: Save compiled workload + if: ${{ always() && steps.rust-cache.outcome == 'success' && steps.rust-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: target/release + key: ${{ steps.rust-cache.outputs.cache-key }} + - name: Package native ARM64 ZIP + shell: pwsh + run: ./scripts/package-release.ps1 -Version $env:PACKAGE_VERSION -Architecture arm64 -OutputDirectory dist -TargetDirectory target + - name: Run ARM64 Windows conformance + shell: pwsh + env: + NEBULA_RESIZE_TRACE: "1" + run: | + python scripts/conformance/windows_standard_user.py scripts/conformance/run.py --app target/release/pebrel.exe --platform windows-aarch64 --output dist/conformance/windows-arm64-report.json + exit $LASTEXITCODE + - name: Upload Windows ARM64 stable asset + if: always() + uses: actions/upload-artifact@v4 + with: + compression-level: 0 + name: stable-package-windows-arm64 + path: dist/Pebrel-v${{ needs.prepare.outputs.version }}-windows-arm64.zip + if-no-files-found: error + retention-days: 14 + - name: Upload Windows ARM64 conformance evidence + if: always() + uses: actions/upload-artifact@v4 + with: + compression-level: 0 + name: stable-evidence-windows-arm64 + path: dist/conformance + if-no-files-found: warn + retention-days: 14 + aggregate: name: Verify stable assets and upload complete set if: ${{ !inputs.windows_only }} - needs: [prepare, native-tests, linux, macos, windows] + needs: [prepare, native-tests, linux, macos, windows, windows-arm64] runs-on: ubuntu-22.04 env: PACKAGE_VERSION: ${{ needs.prepare.outputs.version }} @@ -573,7 +648,7 @@ jobs: import os from pathlib import Path from scripts.preview_release import validate_evidence - validate_evidence(Path("stable-evidence"), os.environ["SOURCE_COMMIT"]) + validate_evidence(Path("stable-evidence"), os.environ["SOURCE_COMMIT"], windows_arm64=True) PY python3 scripts/stable_release.py release-dist \ --version "$PACKAGE_VERSION" \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 57cb33d..045446e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,85 +10,108 @@ Every release entry is provided in English and Simplified Chinese. #### Added -- Added live Markdown editing: focus a paragraph or inline expression to edit its source while retaining the document view, selection and undo history. -- Added Kimi Code recognition, session commands and managed notification hooks. -- Added cloud backup controls with saved preferences, connection feedback and access to configuration backups. -- Added a persistent font-ligature setting that applies to open terminal sessions. +- Added a native Windows ARM64 portable ZIP, alongside the Windows x64 installer and portable ZIP. ARM64 builds use a native runner and include matching ARM64 Hook and console runtime binaries. +- Added Kimi Code recognition, native session commands and managed lifecycle hooks, including its icon and terminal activity states. +- Added a dedicated Agents settings page for Claude Code, Codex, OpenCode, Cursor Agent, Kimi Code, Pi, Oh My Pi, GitHub Copilot and Grok. It shows detected CLI paths and actual Hook installation status beside each integration switch. Automatic Hook management is available on Windows. +- Added configurable notification duration: keep the existing defaults, choose 5, 10, 30 or 90 seconds, or keep notifications visible until dismissed. The selected duration takes precedence over an Agent's default banner lifetime. +- Added a persistent terminal font-ligature setting, enabled by default and applied to open sessions without restarting them. Addresses [#220](https://github.com/Kuddev/pebrel/issues/220). #### Fixed - Fixed Codex cold restoration using a Hook group identifier instead of the native conversation identity. Saved sessions with an older or unrecognized transcript filename retain their existing valid conversation ID. -- Windows local Codex sessions can recover their exact conversation ID and native path from the running process, including when a Hook identity is missing. Later lifecycle-only events retain the verified identity. -- When Codex reports a missing saved conversation, recovery opens its native chooser, including from CMD. Failed restores also offer an explicit conversation choice in the tab menu. -- Closing with unavailable AI session identities now offers saving known state and exiting; save failures and changed editor drafts still keep the application open. -- Fixed file-editor selections and search matches blending into light and dark backgrounds. Ordinary text/code files now reveal the configured workspace background image, and the caret row follows the active theme. -- Fixed copying the terminal working directory: menu and command-palette actions copy the directory reported by the shell, including guest paths, while normal selection copying retains the original text. -- Fixed AI failure and cancellation events being presented as successful completion; Pi retry-aware outcomes and Claude failure events use the shared notification result handling. -- Fixed WSL hook installation and event delivery using the distribution's own user configuration. Updated command completions include the distribution and user options. -- Fixed inherited Ctrl+C suppression preventing interruption of CMD child commands, while keeping selection copying available. -- Fixed native CMD command activity and multiline/pending-wrap input tracking. -- Fixed application 256-color blocks being overwritten by terminal prompt colors. Explicit custom color overrides remain supported. -- Fixed SSH automatic authentication ignoring the available system agent. -- Fixed long Markdown outline headings overlapping neighboring rows. -- Fixed Markdown caret placement for CJK and emoji, interrupted drag selections, heading layout shifts and code-language pickers extending beyond the window. +- Fixed missing Windows local Codex identities by recovering the native conversation ID and file path from the running process. Later lifecycle-only events retain the verified identity. +- Fixed dead-end Codex restoration when a saved conversation no longer exists: recovery opens the native conversation chooser, including from CMD, and the tab menu offers an explicit conversation choice. +- Fixed shutdown getting stuck when some AI identities cannot be collected. Users can save the known state and exit; save errors and subsequently changed editor drafts still keep the application open. +- Fixed AI failures and cancellations appearing as successful completion. Claude failure events and Pi retry-aware outcomes now use the shared result handling. +- Fixed notification replacements retaining an old result or expiry deadline. A refreshed pane result uses the selected duration, and an older timer cannot dismiss its replacement. +- Fixed supported AI confirmation actions by checking that the request is still current and completing the required key-down/key-up sequence. Windows notification activation restores the corresponding application window. +- Fixed WSL Hook installation and event delivery to use the distribution's own user configuration. Command completions include the distribution and user options. +- Fixed inherited Ctrl+C suppression preventing interruption of CMD child commands, while preserving selection copying. +- Fixed native CMD command activity detection, multiline input and echoed text at pending-wrap boundaries. +- Fixed application 256-color blocks being overwritten by terminal prompt colors, while retaining explicit custom color overrides. +- Fixed saved SSH hosts ignoring the available system SSH agent in automatic authentication mode. +- Fixed terminal working-directory copying to use the path reported by the shell, including guest paths, while normal selection copying retains the original text. +- Fixed terminal and answer-reader opening of supported local Markdown links, file URIs and relative paths. Paths containing spaces, Chinese text or parentheses resolve against the originating pane's directory; missing paths produce visible feedback. Addresses [#203](https://github.com/Kuddev/pebrel/issues/203). +- Fixed file-editor selections and search matches blending into light and dark backgrounds. Plain-text and code editors reveal the configured workspace background, and the active line follows the theme. +- Fixed long Markdown outline headings overlapping adjacent rows. Full titles remain available through tooltips and copying. Addresses [#184](https://github.com/Kuddev/pebrel/issues/184). +- Fixed interrupted Markdown drags leaving a selection that continues to grow when the pointer moves without a pressed button. +- Fixed code-language menus extending beyond narrow windows and kept their search field and keyboard activation usable. +- Fixed SSH host icons appearing off-center because a font glyph's visible outline differs from its text advance. +- Removed the duplicate title preview shown while dragging a tab. #### Improved -- Added direct responses for supported AI confirmation notifications, with stale-request checks before sending a response; Windows notification activation restores the corresponding application window. -- Made notification duration configurable and preserved the selected duration when replacing a pane's result. -- Reduced repeated Git/SVN drawer work by reusing repository snapshots and rendering visible rows. -- Improved settings search, SSH connection cards and terminal file-link opening. -- Made cloud-storage configuration compact, bounded provider and credential fields, and aligned file-panel controls. -- Updated command-group navigation, ordering and add actions. -- Added settings for enabling each supported Agent integration while retaining other managed and user-owned hook entries. +- Markdown opens in read-only rendered mode. Text selection, copying, code copying and formula actions remain available; editing requires an explicit switch to source mode. +- Code-block language selection remains available in the reader. It changes syntax highlighting without rewriting the Markdown, survives scrolling away and back, and leaves copied code unchanged. +- Kept source-mode drafts when returning to the reader, while preventing undo/redo shortcuts from editing the document in read-only mode. +- Bounded Markdown presentation work with virtual rows, visible-block image ownership and release of inactive document layouts. These changes reduce repeated work without imposing a machine-specific memory or speed promise. +- Reused repository snapshots and rendered visible Git/SVN drawer rows to reduce repeated work in large repositories. +- Centered the Agents page, enlarged all nine Agent marks using SVG assets, and separated CLI discovery from Hook installation status. Long paths remain accessible through tooltips. +- Improved settings search, SSH connection cards and filters, file-panel control alignment, and command-group navigation, ordering and add actions. +- Preserved other integrations and user-owned configuration when installing or removing a managed Agent Hook. +- Temporarily hid the backup entry from the settings sidebar and search; cloud-backup controls are not exposed in this release. -Windows packages include an installer and portable ZIP. Linux and macOS packages retain their Preview designation. This candidate is intended for installation testing; visual acceptance and live cold restoration with every external CLI remain separate from automated checks. +Windows x64 includes an installer and portable ZIP; Windows ARM64 includes a native portable ZIP. The installer and in-app automatic installation remain x64-only. Linux x64 packages and macOS Apple Silicon/Intel DMGs retain their Preview designation. macOS requires version 14 or later. Molecular structure rendering remains disabled. ### 中文 #### 新增 -- 新增 Markdown 即时编辑:聚焦段落或行内表达式即可编辑对应源码,同时保留文档视图、选区和撤销历史。 -- 新增 Kimi Code 识别、会话命令和受管理的通知 Hook。 -- 新增云端备份入口,支持保存偏好、连接反馈和访问配置备份。 -- 新增可持久保存的字体连字设置,并可应用于已打开的终端会话。 +- 新增 Windows ARM64 原生便携 ZIP,与 Windows x64 安装器及便携 ZIP 一同提供。ARM64 使用原生运行器构建,并配套 ARM64 Hook 辅助程序与控制台运行时。 +- 新增 Kimi Code 识别、原生会话命令和受管理的生命周期 Hook,包含品牌图标及终端活动状态。 +- 新增独立 Agents 设置页,覆盖 Claude Code、Codex、OpenCode、Cursor Agent、Kimi Code、Pi、Oh My Pi、GitHub Copilot 和 Grok。每项显示检测到的 CLI 路径,并在开关旁显示实际 Hook 安装状态;自动 Hook 管理适用于 Windows。 +- 新增通知停留时长设置:保持既有默认值,或选择 5、10、30、90 秒以及手动关闭。用户选择的时长优先于 Agent 自带的横幅默认时长。 +- 新增可持久保存的终端字体连字开关,默认开启,并可应用于已打开的会话,无需重启终端。对应 [#220](https://github.com/Kuddev/pebrel/issues/220)。 #### 修复 - 修复 Codex 冷恢复误用 Hook 分组标识而非原生对话身份的问题。旧快照中的会话文件名无法识别时,保留原有有效对话 ID。 -- Windows 本地 Codex 会话可从正在运行的进程找回准确的对话 ID 和原生文件路径,补足 Hook 身份缺失的情况;后续仅包含生命周期信息的事件会保留已核验身份。 -- Codex 报告保存的对话不存在时,包括 CMD 在内的恢复流程可打开原生会话选择器;恢复失败后,也可从标签页菜单主动选择对话。 -- AI 会话身份暂时无法取得时,关闭操作提供保存已知状态并退出的选择;保存失败或编辑器草稿发生变化时仍保持应用打开。 -- 修复文件编辑器选区与查找命中融入浅色、深色背景的问题;普通文本和代码文件可透出已配置的工作区背景图,光标所在行跟随当前主题。 -- 修复终端工作目录复制:菜单和命令面板复制 Shell 上报的目录,包括访客环境路径;普通选区复制保留原始文字。 -- 修复 AI 失败和取消事件被显示为成功完成的问题;Pi 重试后的结果及 Claude 失败事件统一进入通知结果处理。 +- 修复 Windows 本地 Codex 身份缺失的问题,可从运行中的进程找回原生对话 ID 和文件路径;后续仅包含生命周期信息的事件会保留已核验身份。 +- 修复保存的 Codex 对话不存在时恢复流程无法继续的问题:包括 CMD 在内,可打开原生会话选择器,标签菜单也提供主动选择对话的入口。 +- 修复部分 AI 身份无法取得时关闭流程卡住的问题。用户可以保存已知状态并退出;保存失败或编辑器草稿随后发生变化时仍保持应用打开。 +- 修复 AI 失败和取消事件被显示为成功完成的问题,Claude 失败事件及 Pi 重试后的结果统一进入通知结果处理。 +- 修复通知替换后残留旧结果或旧到期时间的问题。新的窗格结果遵循选定时长,旧计时器不能关闭替换后的通知。 +- 修复受支持 AI 确认动作:发送前检查请求仍然有效,并完成必要的按下、释放按键序列;Windows 通知激活时恢复对应应用窗口。 - 修复 WSL Hook 安装与事件传递,使用发行版自身的用户配置;命令补全同步包含发行版和用户选项。 - 修复继承的 Ctrl+C 忽略状态导致 CMD 子命令无法中断的问题,同时保留选区复制。 -- 修复原生 CMD 命令活动、多行输入和等待折行边界的输入跟踪。 +- 修复原生 CMD 命令活动检测、多行输入以及等待折行边界的回显文本跟踪。 - 修复应用的 256 色块被终端提示符颜色覆盖的问题,保留明确配置的自定义颜色覆盖。 -- 修复 SSH 自动认证忽略可用系统代理的问题。 -- 修复 Markdown 目录长标题与相邻行重叠的问题。 -- 修复 Markdown 中文与 emoji 的光标落点、中断拖动后残留选区、标题编辑时布局跳动以及代码语言菜单超出窗口的问题。 +- 修复已保存 SSH 主机在自动认证时忽略可用系统 SSH 代理的问题。 +- 修复终端工作目录复制,改为复制 Shell 上报的路径,包括访客环境路径;普通选区复制保留原始文字。 +- 修复终端和回答阅读页打开受支持的本地 Markdown 链接、文件 URI 及相对路径的行为。含空格、中文或括号的路径按原窗格目录解析,路径缺失时提供可见反馈。对应 [#203](https://github.com/Kuddev/pebrel/issues/203)。 +- 修复文件编辑器选区与查找命中融入浅色、深色背景的问题;普通文本和代码编辑器可透出已配置的工作区背景图,光标所在行跟随主题。 +- 修复 Markdown 目录长标题压住相邻行的问题,完整标题仍可通过悬停提示和复制取得。对应 [#184](https://github.com/Kuddev/pebrel/issues/184)。 +- 修复 Markdown 拖选被中断后,未按住鼠标移动仍继续扩大选区的问题。 +- 修复代码语言菜单在窄窗口中越界的问题,并保持搜索框和键盘操作可用。 +- 修复 SSH 主机图标因字体可见轮廓与排版宽度不同而偏离中心的问题。 +- 移除拖动标签时重复出现的标题预览。 #### 改进 -- 支持从部分 AI 确认通知直接回答,发送前检查请求是否过期;Windows 通知激活时恢复对应的应用窗口。 -- 支持配置通知停留时长,并在替换窗格结果时保留选定时长。 -- 复用仓库快照、仅渲染可见行,减少 Git/SVN 抽屉的重复工作。 -- 改进设置搜索、SSH 连接卡片和终端文件链接打开。 -- 收紧云存储配置布局,限制服务选择器和凭据输入宽度,并统一文件面板控件尺寸。 -- 更新命令分组导航、排序和新增操作的布局。 -- 新增各受支持 Agent 的集成开关,安装与移除时保留其他受管理及用户已有 Hook 项。 +- Markdown 默认以只读方式渲染,保留文字选择、复制、代码复制及公式操作;编辑需要显式切换到源码模式。 +- 阅读模式仍可选择代码块语言,只改变语法高亮,不改写 Markdown;滚动离开再返回后保留选择,复制得到的代码内容不变。 +- 从源码模式返回阅读页时保留草稿,同时阻止只读模式下的撤销、重做快捷键修改文档。 +- Markdown 使用虚拟列表、可见代码块和图片资源管理,并释放非活动文档的布局缓存,减少重复工作;不把单机结果写成固定的内存或速度承诺。 +- Git/SVN 抽屉复用仓库快照,仅渲染可见条目,减少大型仓库中的重复工作。 +- Agents 页面整体居中,九种 Agent 图标使用放大的 SVG 资源;CLI 是否存在与 Hook 是否安装分别展示,长路径可通过悬停提示查看。 +- 改进设置搜索、SSH 连接卡片和筛选、文件面板控件对齐,以及命令分组的导航、排序和新增操作。 +- 安装或移除受管理的 Agent Hook 时,保留其他集成及用户已有配置。 +- 暂时隐藏设置侧栏和搜索中的备份入口,本版不开放云备份控件。 -Windows 提供安装器和 ZIP 便携包,Linux 和 macOS 包继续标记为 Preview。本候选用于安装测试;视觉验收和所有外部 CLI 的实际关窗恢复仍需单独验证。 +Windows x64 提供安装器和 ZIP 便携包,Windows ARM64 提供原生便携 ZIP;安装器和应用内自动安装仍仅支持 x64。Linux x64 包及 macOS Apple Silicon/Intel DMG 继续标记为 Preview。macOS 最低版本为 14。分子结构渲染仍处于禁用状态。 ### Contributors -@Traveritas avatar@galact-byte avatar@Kuddev avatar +@Traveritas avatar@galact-byte avatar@821869798 avatar@tataraDM avatar@Aschenbath avatar@Kuddev avatar -- **[@Traveritas](https://github.com/Traveritas)** — Kimi Code integration. / Kimi Code 集成。([#197](https://github.com/Kuddev/pebrel/pull/197)) -- **[@galact-byte](https://github.com/galact-byte)** — CMD interrupts and activity, application colors and Pi notification outcomes. / CMD 中断与活动、应用配色及 Pi 通知结果。([#206](https://github.com/Kuddev/pebrel/pull/206), [#207](https://github.com/Kuddev/pebrel/pull/207), [#208](https://github.com/Kuddev/pebrel/pull/208), [#215](https://github.com/Kuddev/pebrel/pull/215), [#216](https://github.com/Kuddev/pebrel/pull/216)) -- **[@Kuddev](https://github.com/Kuddev)** — Desktop integration, notifications, restoration and editor fixes. / 桌面集成、通知、恢复及编辑器修复。([#221](https://github.com/Kuddev/pebrel/pull/221), [#225](https://github.com/Kuddev/pebrel/pull/225), [#227](https://github.com/Kuddev/pebrel/pull/227), [#231](https://github.com/Kuddev/pebrel/pull/231), [#232](https://github.com/Kuddev/pebrel/pull/232)) +- **[@Traveritas](https://github.com/Traveritas)** — Kimi Code hooks, recognition and branding. / Kimi Code Hook、识别和品牌图标。([#197](https://github.com/Kuddev/pebrel/pull/197)) +- **[@galact-byte](https://github.com/galact-byte)** — CMD interrupts and activity, terminal application colors and Pi notification outcomes. / CMD 中断与活动、终端应用配色及 Pi 通知结果。([#206](https://github.com/Kuddev/pebrel/pull/206), [#207](https://github.com/Kuddev/pebrel/pull/207), [#208](https://github.com/Kuddev/pebrel/pull/208), [#215](https://github.com/Kuddev/pebrel/pull/215), [#216](https://github.com/Kuddev/pebrel/pull/216)) +- **[@821869798](https://github.com/821869798)** — Markdown links and local file opening from terminals and the answer reader. / 终端及回答阅读页的 Markdown 链接和本地文件打开。([#204](https://github.com/Kuddev/pebrel/pull/204)) +- **[@tataraDM](https://github.com/tataraDM)** — Readable single-line Markdown outline headings. / 修复 Markdown 目录长标题挤压。([#191](https://github.com/Kuddev/pebrel/pull/191)) +- **[@Aschenbath](https://github.com/Aschenbath)** — Configurable notification duration and persistent notifications. / 可配置的通知停留时长和常驻通知。([#131](https://github.com/Kuddev/pebrel/pull/131)) +- **[@Kuddev](https://github.com/Kuddev)** — Desktop integration, actionable notifications, session restoration, Markdown reader, Agents UI, review and release integration. / 桌面整合、可操作通知、会话恢复、Markdown 阅读、Agents 界面、审核及发布整合。([#210](https://github.com/Kuddev/pebrel/pull/210), [#217](https://github.com/Kuddev/pebrel/pull/217), [#221](https://github.com/Kuddev/pebrel/pull/221), [#222](https://github.com/Kuddev/pebrel/pull/222), [#225](https://github.com/Kuddev/pebrel/pull/225), [#227](https://github.com/Kuddev/pebrel/pull/227), [#231](https://github.com/Kuddev/pebrel/pull/231), [#232](https://github.com/Kuddev/pebrel/pull/232)) + +--- **SHA256** @@ -99,6 +122,7 @@ Windows 提供安装器和 ZIP 便携包,Linux 和 macOS 包继续标记为 Pr - `Pebrel-v1.9.0-macos-x64-preview.dmg`: `PENDING FINAL BUILD` - `Pebrel-v1.9.0-windows-x64.zip`: `PENDING FINAL BUILD` - `Pebrel-v1.9.0-windows-x64-setup.exe`: `PENDING FINAL BUILD` +- `Pebrel-v1.9.0-windows-arm64.zip`: `PENDING FINAL BUILD` ## 1.8.2 - 2026-09-17 diff --git a/architecture/notes/packaging/2026-09-21-windows-arm64-portable.md b/architecture/notes/packaging/2026-09-21-windows-arm64-portable.md new file mode 100644 index 0000000..7b13fe8 --- /dev/null +++ b/architecture/notes/packaging/2026-09-21-windows-arm64-portable.md @@ -0,0 +1,65 @@ +# Native Windows ARM64 portable release + +## Status + +Implemented for the 1.9.0 release candidate, following the maintainer's request +to include Windows ARM builds in release CI. Public release remains pending. + +## Context + +The native test matrix already includes Windows ARM64, while stable release +packaging previously produced only Windows x64 binaries. Passing ARM64 tests +did not give ARM users a downloadable native application. + +## Evidence + +- `linux-lua.yml` runs tests on `windows-11-arm` and checks the compiler host. +- `package-release.ps1` previously fixed the archive suffix to `windows-x64`. +- The existing console-runtime preparation script has pinned ARM64 payloads. +- The installer and automatic installer selection currently target Windows x64. + +## Decision + +Add a native ARM64 portable ZIP to the stable asset manifest from 1.9.0 onward. +Build the application and Hook helper on the ARM64 runner with the shared +product build script. Package the corresponding pinned ARM64 console runtime. +Verify PE machine headers for all four executable payloads before assigning +the requested architecture to the ZIP filename. + +The full release aggregate requires both the existing native ARM64 test job and +an ARM64 conformance report from the same source commit. Historical release +manifests and the separate Preview workflow retain their current contracts. + +Keep the Windows installer and automatic installation on x64 for this release. +The ARM64 artifact is explicitly described as a portable ZIP; an ARM64 installer +requires its own native installation, migration and update-contract validation. + +## Rejected alternatives + +- Renaming an x64 archive would not provide native ARM64 execution. +- Cross-compilation alone would not exercise native startup and terminal behavior. +- Treating the native test job as packaging evidence would leave downloadable + payloads and architecture mismatches unverified. + +## Consequences + +The 1.9.0 stable manifest contains eight packages plus SHA256SUMS. The additional +release job owns its runner, architecture-specific build cache and evidence. +No installer compatibility alias or historical asset is removed. + +## Validation + +- Package-header tests accept both supported machine types and reject a wrong + label, mismatched Hook helper and truncated executable. +- Release-manifest tests require the ARM64 ZIP for 1.9.0 and retain old manifests. +- Evidence tests reject missing or mislabeled ARM64 reports when ARM64 is required. +- Native release CI is required before attaching candidate assets to the draft. + +## Supersedes + +None. + +## Revisit when + +A native ARM64 installer and its update/migration behavior have been implemented +and verified, or the supported Windows release architectures change. diff --git a/docs/release-notes/v1.9.0.md b/docs/release-notes/v1.9.0.md index b549e99..88eaf8a 100644 --- a/docs/release-notes/v1.9.0.md +++ b/docs/release-notes/v1.9.0.md @@ -4,85 +4,108 @@ ### Added -- Added live Markdown editing: focus a paragraph or inline expression to edit its source while retaining the document view, selection and undo history. -- Added Kimi Code recognition, session commands and managed notification hooks. -- Added cloud backup controls with saved preferences, connection feedback and access to configuration backups. -- Added a persistent font-ligature setting that applies to open terminal sessions. +- Added a native Windows ARM64 portable ZIP, alongside the Windows x64 installer and portable ZIP. ARM64 builds use a native runner and include matching ARM64 Hook and console runtime binaries. +- Added Kimi Code recognition, native session commands and managed lifecycle hooks, including its icon and terminal activity states. +- Added a dedicated Agents settings page for Claude Code, Codex, OpenCode, Cursor Agent, Kimi Code, Pi, Oh My Pi, GitHub Copilot and Grok. It shows detected CLI paths and actual Hook installation status beside each integration switch. Automatic Hook management is available on Windows. +- Added configurable notification duration: keep the existing defaults, choose 5, 10, 30 or 90 seconds, or keep notifications visible until dismissed. The selected duration takes precedence over an Agent's default banner lifetime. +- Added a persistent terminal font-ligature setting, enabled by default and applied to open sessions without restarting them. Addresses [#220](https://github.com/Kuddev/pebrel/issues/220). ### Fixed - Fixed Codex cold restoration using a Hook group identifier instead of the native conversation identity. Saved sessions with an older or unrecognized transcript filename retain their existing valid conversation ID. -- Windows local Codex sessions can recover their exact conversation ID and native path from the running process, including when a Hook identity is missing. Later lifecycle-only events retain the verified identity. -- When Codex reports a missing saved conversation, recovery opens its native chooser, including from CMD. Failed restores also offer an explicit conversation choice in the tab menu. -- Closing with unavailable AI session identities now offers saving known state and exiting; save failures and changed editor drafts still keep the application open. -- Fixed file-editor selections and search matches blending into light and dark backgrounds. Ordinary text/code files now reveal the configured workspace background image, and the caret row follows the active theme. -- Fixed copying the terminal working directory: menu and command-palette actions copy the directory reported by the shell, including guest paths, while normal selection copying retains the original text. -- Fixed AI failure and cancellation events being presented as successful completion; Pi retry-aware outcomes and Claude failure events use the shared notification result handling. -- Fixed WSL hook installation and event delivery using the distribution's own user configuration. Updated command completions include the distribution and user options. -- Fixed inherited Ctrl+C suppression preventing interruption of CMD child commands, while keeping selection copying available. -- Fixed native CMD command activity and multiline/pending-wrap input tracking. -- Fixed application 256-color blocks being overwritten by terminal prompt colors. Explicit custom color overrides remain supported. -- Fixed SSH automatic authentication ignoring the available system agent. -- Fixed long Markdown outline headings overlapping neighboring rows. -- Fixed Markdown caret placement for CJK and emoji, interrupted drag selections, heading layout shifts and code-language pickers extending beyond the window. +- Fixed missing Windows local Codex identities by recovering the native conversation ID and file path from the running process. Later lifecycle-only events retain the verified identity. +- Fixed dead-end Codex restoration when a saved conversation no longer exists: recovery opens the native conversation chooser, including from CMD, and the tab menu offers an explicit conversation choice. +- Fixed shutdown getting stuck when some AI identities cannot be collected. Users can save the known state and exit; save errors and subsequently changed editor drafts still keep the application open. +- Fixed AI failures and cancellations appearing as successful completion. Claude failure events and Pi retry-aware outcomes now use the shared result handling. +- Fixed notification replacements retaining an old result or expiry deadline. A refreshed pane result uses the selected duration, and an older timer cannot dismiss its replacement. +- Fixed supported AI confirmation actions by checking that the request is still current and completing the required key-down/key-up sequence. Windows notification activation restores the corresponding application window. +- Fixed WSL Hook installation and event delivery to use the distribution's own user configuration. Command completions include the distribution and user options. +- Fixed inherited Ctrl+C suppression preventing interruption of CMD child commands, while preserving selection copying. +- Fixed native CMD command activity detection, multiline input and echoed text at pending-wrap boundaries. +- Fixed application 256-color blocks being overwritten by terminal prompt colors, while retaining explicit custom color overrides. +- Fixed saved SSH hosts ignoring the available system SSH agent in automatic authentication mode. +- Fixed terminal working-directory copying to use the path reported by the shell, including guest paths, while normal selection copying retains the original text. +- Fixed terminal and answer-reader opening of supported local Markdown links, file URIs and relative paths. Paths containing spaces, Chinese text or parentheses resolve against the originating pane's directory; missing paths produce visible feedback. Addresses [#203](https://github.com/Kuddev/pebrel/issues/203). +- Fixed file-editor selections and search matches blending into light and dark backgrounds. Plain-text and code editors reveal the configured workspace background, and the active line follows the theme. +- Fixed long Markdown outline headings overlapping adjacent rows. Full titles remain available through tooltips and copying. Addresses [#184](https://github.com/Kuddev/pebrel/issues/184). +- Fixed interrupted Markdown drags leaving a selection that continues to grow when the pointer moves without a pressed button. +- Fixed code-language menus extending beyond narrow windows and kept their search field and keyboard activation usable. +- Fixed SSH host icons appearing off-center because a font glyph's visible outline differs from its text advance. +- Removed the duplicate title preview shown while dragging a tab. ### Improved -- Added direct responses for supported AI confirmation notifications, with stale-request checks before sending a response; Windows notification activation restores the corresponding application window. -- Made notification duration configurable and preserved the selected duration when replacing a pane's result. -- Reduced repeated Git/SVN drawer work by reusing repository snapshots and rendering visible rows. -- Improved settings search, SSH connection cards and terminal file-link opening. -- Made cloud-storage configuration compact, bounded provider and credential fields, and aligned file-panel controls. -- Updated command-group navigation, ordering and add actions. -- Added settings for enabling each supported Agent integration while retaining other managed and user-owned hook entries. +- Markdown opens in read-only rendered mode. Text selection, copying, code copying and formula actions remain available; editing requires an explicit switch to source mode. +- Code-block language selection remains available in the reader. It changes syntax highlighting without rewriting the Markdown, survives scrolling away and back, and leaves copied code unchanged. +- Kept source-mode drafts when returning to the reader, while preventing undo/redo shortcuts from editing the document in read-only mode. +- Bounded Markdown presentation work with virtual rows, visible-block image ownership and release of inactive document layouts. These changes reduce repeated work without imposing a machine-specific memory or speed promise. +- Reused repository snapshots and rendered visible Git/SVN drawer rows to reduce repeated work in large repositories. +- Centered the Agents page, enlarged all nine Agent marks using SVG assets, and separated CLI discovery from Hook installation status. Long paths remain accessible through tooltips. +- Improved settings search, SSH connection cards and filters, file-panel control alignment, and command-group navigation, ordering and add actions. +- Preserved other integrations and user-owned configuration when installing or removing a managed Agent Hook. +- Temporarily hid the backup entry from the settings sidebar and search; cloud-backup controls are not exposed in this release. -Windows packages include an installer and portable ZIP. Linux and macOS packages retain their Preview designation. This candidate is intended for installation testing; visual acceptance and live cold restoration with every external CLI remain separate from automated checks. +Windows x64 includes an installer and portable ZIP; Windows ARM64 includes a native portable ZIP. The installer and in-app automatic installation remain x64-only. Linux x64 packages and macOS Apple Silicon/Intel DMGs retain their Preview designation. macOS requires version 14 or later. Molecular structure rendering remains disabled. ## 中文 ### 新增 -- 新增 Markdown 即时编辑:聚焦段落或行内表达式即可编辑对应源码,同时保留文档视图、选区和撤销历史。 -- 新增 Kimi Code 识别、会话命令和受管理的通知 Hook。 -- 新增云端备份入口,支持保存偏好、连接反馈和访问配置备份。 -- 新增可持久保存的字体连字设置,并可应用于已打开的终端会话。 +- 新增 Windows ARM64 原生便携 ZIP,与 Windows x64 安装器及便携 ZIP 一同提供。ARM64 使用原生运行器构建,并配套 ARM64 Hook 辅助程序与控制台运行时。 +- 新增 Kimi Code 识别、原生会话命令和受管理的生命周期 Hook,包含品牌图标及终端活动状态。 +- 新增独立 Agents 设置页,覆盖 Claude Code、Codex、OpenCode、Cursor Agent、Kimi Code、Pi、Oh My Pi、GitHub Copilot 和 Grok。每项显示检测到的 CLI 路径,并在开关旁显示实际 Hook 安装状态;自动 Hook 管理适用于 Windows。 +- 新增通知停留时长设置:保持既有默认值,或选择 5、10、30、90 秒以及手动关闭。用户选择的时长优先于 Agent 自带的横幅默认时长。 +- 新增可持久保存的终端字体连字开关,默认开启,并可应用于已打开的会话,无需重启终端。对应 [#220](https://github.com/Kuddev/pebrel/issues/220)。 ### 修复 - 修复 Codex 冷恢复误用 Hook 分组标识而非原生对话身份的问题。旧快照中的会话文件名无法识别时,保留原有有效对话 ID。 -- Windows 本地 Codex 会话可从正在运行的进程找回准确的对话 ID 和原生文件路径,补足 Hook 身份缺失的情况;后续仅包含生命周期信息的事件会保留已核验身份。 -- Codex 报告保存的对话不存在时,包括 CMD 在内的恢复流程可打开原生会话选择器;恢复失败后,也可从标签页菜单主动选择对话。 -- AI 会话身份暂时无法取得时,关闭操作提供保存已知状态并退出的选择;保存失败或编辑器草稿发生变化时仍保持应用打开。 -- 修复文件编辑器选区与查找命中融入浅色、深色背景的问题;普通文本和代码文件可透出已配置的工作区背景图,光标所在行跟随当前主题。 -- 修复终端工作目录复制:菜单和命令面板复制 Shell 上报的目录,包括访客环境路径;普通选区复制保留原始文字。 -- 修复 AI 失败和取消事件被显示为成功完成的问题;Pi 重试后的结果及 Claude 失败事件统一进入通知结果处理。 +- 修复 Windows 本地 Codex 身份缺失的问题,可从运行中的进程找回原生对话 ID 和文件路径;后续仅包含生命周期信息的事件会保留已核验身份。 +- 修复保存的 Codex 对话不存在时恢复流程无法继续的问题:包括 CMD 在内,可打开原生会话选择器,标签菜单也提供主动选择对话的入口。 +- 修复部分 AI 身份无法取得时关闭流程卡住的问题。用户可以保存已知状态并退出;保存失败或编辑器草稿随后发生变化时仍保持应用打开。 +- 修复 AI 失败和取消事件被显示为成功完成的问题,Claude 失败事件及 Pi 重试后的结果统一进入通知结果处理。 +- 修复通知替换后残留旧结果或旧到期时间的问题。新的窗格结果遵循选定时长,旧计时器不能关闭替换后的通知。 +- 修复受支持 AI 确认动作:发送前检查请求仍然有效,并完成必要的按下、释放按键序列;Windows 通知激活时恢复对应应用窗口。 - 修复 WSL Hook 安装与事件传递,使用发行版自身的用户配置;命令补全同步包含发行版和用户选项。 - 修复继承的 Ctrl+C 忽略状态导致 CMD 子命令无法中断的问题,同时保留选区复制。 -- 修复原生 CMD 命令活动、多行输入和等待折行边界的输入跟踪。 +- 修复原生 CMD 命令活动检测、多行输入以及等待折行边界的回显文本跟踪。 - 修复应用的 256 色块被终端提示符颜色覆盖的问题,保留明确配置的自定义颜色覆盖。 -- 修复 SSH 自动认证忽略可用系统代理的问题。 -- 修复 Markdown 目录长标题与相邻行重叠的问题。 -- 修复 Markdown 中文与 emoji 的光标落点、中断拖动后残留选区、标题编辑时布局跳动以及代码语言菜单超出窗口的问题。 +- 修复已保存 SSH 主机在自动认证时忽略可用系统 SSH 代理的问题。 +- 修复终端工作目录复制,改为复制 Shell 上报的路径,包括访客环境路径;普通选区复制保留原始文字。 +- 修复终端和回答阅读页打开受支持的本地 Markdown 链接、文件 URI 及相对路径的行为。含空格、中文或括号的路径按原窗格目录解析,路径缺失时提供可见反馈。对应 [#203](https://github.com/Kuddev/pebrel/issues/203)。 +- 修复文件编辑器选区与查找命中融入浅色、深色背景的问题;普通文本和代码编辑器可透出已配置的工作区背景图,光标所在行跟随主题。 +- 修复 Markdown 目录长标题压住相邻行的问题,完整标题仍可通过悬停提示和复制取得。对应 [#184](https://github.com/Kuddev/pebrel/issues/184)。 +- 修复 Markdown 拖选被中断后,未按住鼠标移动仍继续扩大选区的问题。 +- 修复代码语言菜单在窄窗口中越界的问题,并保持搜索框和键盘操作可用。 +- 修复 SSH 主机图标因字体可见轮廓与排版宽度不同而偏离中心的问题。 +- 移除拖动标签时重复出现的标题预览。 ### 改进 -- 支持从部分 AI 确认通知直接回答,发送前检查请求是否过期;Windows 通知激活时恢复对应的应用窗口。 -- 支持配置通知停留时长,并在替换窗格结果时保留选定时长。 -- 复用仓库快照、仅渲染可见行,减少 Git/SVN 抽屉的重复工作。 -- 改进设置搜索、SSH 连接卡片和终端文件链接打开。 -- 收紧云存储配置布局,限制服务选择器和凭据输入宽度,并统一文件面板控件尺寸。 -- 更新命令分组导航、排序和新增操作的布局。 -- 新增各受支持 Agent 的集成开关,安装与移除时保留其他受管理及用户已有 Hook 项。 +- Markdown 默认以只读方式渲染,保留文字选择、复制、代码复制及公式操作;编辑需要显式切换到源码模式。 +- 阅读模式仍可选择代码块语言,只改变语法高亮,不改写 Markdown;滚动离开再返回后保留选择,复制得到的代码内容不变。 +- 从源码模式返回阅读页时保留草稿,同时阻止只读模式下的撤销、重做快捷键修改文档。 +- Markdown 使用虚拟列表、可见代码块和图片资源管理,并释放非活动文档的布局缓存,减少重复工作;不把单机结果写成固定的内存或速度承诺。 +- Git/SVN 抽屉复用仓库快照,仅渲染可见条目,减少大型仓库中的重复工作。 +- Agents 页面整体居中,九种 Agent 图标使用放大的 SVG 资源;CLI 是否存在与 Hook 是否安装分别展示,长路径可通过悬停提示查看。 +- 改进设置搜索、SSH 连接卡片和筛选、文件面板控件对齐,以及命令分组的导航、排序和新增操作。 +- 安装或移除受管理的 Agent Hook 时,保留其他集成及用户已有配置。 +- 暂时隐藏设置侧栏和搜索中的备份入口,本版不开放云备份控件。 -Windows 提供安装器和 ZIP 便携包,Linux 和 macOS 包继续标记为 Preview。本候选用于安装测试;视觉验收和所有外部 CLI 的实际关窗恢复仍需单独验证。 +Windows x64 提供安装器和 ZIP 便携包,Windows ARM64 提供原生便携 ZIP;安装器和应用内自动安装仍仅支持 x64。Linux x64 包及 macOS Apple Silicon/Intel DMG 继续标记为 Preview。macOS 最低版本为 14。分子结构渲染仍处于禁用状态。 ## Contributors -@Traveritas avatar@galact-byte avatar@Kuddev avatar +@Traveritas avatar@galact-byte avatar@821869798 avatar@tataraDM avatar@Aschenbath avatar@Kuddev avatar -- **[@Traveritas](https://github.com/Traveritas)** — Kimi Code integration. / Kimi Code 集成。([#197](https://github.com/Kuddev/pebrel/pull/197)) -- **[@galact-byte](https://github.com/galact-byte)** — CMD interrupts and activity, application colors and Pi notification outcomes. / CMD 中断与活动、应用配色及 Pi 通知结果。([#206](https://github.com/Kuddev/pebrel/pull/206), [#207](https://github.com/Kuddev/pebrel/pull/207), [#208](https://github.com/Kuddev/pebrel/pull/208), [#215](https://github.com/Kuddev/pebrel/pull/215), [#216](https://github.com/Kuddev/pebrel/pull/216)) -- **[@Kuddev](https://github.com/Kuddev)** — Desktop integration, notifications, restoration and editor fixes. / 桌面集成、通知、恢复及编辑器修复。([#221](https://github.com/Kuddev/pebrel/pull/221), [#225](https://github.com/Kuddev/pebrel/pull/225), [#227](https://github.com/Kuddev/pebrel/pull/227), [#231](https://github.com/Kuddev/pebrel/pull/231), [#232](https://github.com/Kuddev/pebrel/pull/232)) +- **[@Traveritas](https://github.com/Traveritas)** — Kimi Code hooks, recognition and branding. / Kimi Code Hook、识别和品牌图标。([#197](https://github.com/Kuddev/pebrel/pull/197)) +- **[@galact-byte](https://github.com/galact-byte)** — CMD interrupts and activity, terminal application colors and Pi notification outcomes. / CMD 中断与活动、终端应用配色及 Pi 通知结果。([#206](https://github.com/Kuddev/pebrel/pull/206), [#207](https://github.com/Kuddev/pebrel/pull/207), [#208](https://github.com/Kuddev/pebrel/pull/208), [#215](https://github.com/Kuddev/pebrel/pull/215), [#216](https://github.com/Kuddev/pebrel/pull/216)) +- **[@821869798](https://github.com/821869798)** — Markdown links and local file opening from terminals and the answer reader. / 终端及回答阅读页的 Markdown 链接和本地文件打开。([#204](https://github.com/Kuddev/pebrel/pull/204)) +- **[@tataraDM](https://github.com/tataraDM)** — Readable single-line Markdown outline headings. / 修复 Markdown 目录长标题挤压。([#191](https://github.com/Kuddev/pebrel/pull/191)) +- **[@Aschenbath](https://github.com/Aschenbath)** — Configurable notification duration and persistent notifications. / 可配置的通知停留时长和常驻通知。([#131](https://github.com/Kuddev/pebrel/pull/131)) +- **[@Kuddev](https://github.com/Kuddev)** — Desktop integration, actionable notifications, session restoration, Markdown reader, Agents UI, review and release integration. / 桌面整合、可操作通知、会话恢复、Markdown 阅读、Agents 界面、审核及发布整合。([#210](https://github.com/Kuddev/pebrel/pull/210), [#217](https://github.com/Kuddev/pebrel/pull/217), [#221](https://github.com/Kuddev/pebrel/pull/221), [#222](https://github.com/Kuddev/pebrel/pull/222), [#225](https://github.com/Kuddev/pebrel/pull/225), [#227](https://github.com/Kuddev/pebrel/pull/227), [#231](https://github.com/Kuddev/pebrel/pull/231), [#232](https://github.com/Kuddev/pebrel/pull/232)) + +--- **SHA256** @@ -93,3 +116,4 @@ Windows 提供安装器和 ZIP 便携包,Linux 和 macOS 包继续标记为 Pr - `Pebrel-v1.9.0-macos-x64-preview.dmg`: `PENDING FINAL BUILD` - `Pebrel-v1.9.0-windows-x64.zip`: `PENDING FINAL BUILD` - `Pebrel-v1.9.0-windows-x64-setup.exe`: `PENDING FINAL BUILD` +- `Pebrel-v1.9.0-windows-arm64.zip`: `PENDING FINAL BUILD` diff --git a/scripts/package-release.ps1 b/scripts/package-release.ps1 index 671146d..378d670 100644 --- a/scripts/package-release.ps1 +++ b/scripts/package-release.ps1 @@ -9,6 +9,9 @@ param( [ValidateSet('NebulaTerminal', 'Pebrel')] [string] $PackageBrand = 'Pebrel', + [ValidateSet('x64', 'arm64')] + [string] $Architecture = 'x64', + [switch] $SkipBuild, # 与 -SkipBuild 联用:跳过「exe 必须比源码新」的陈旧检查。仅用于脚本 # 自测;发布产物一律走全新构建。 @@ -45,8 +48,8 @@ $cargoTargetRoot = [System.IO.Path]::GetFullPath($TargetDirectory) $outputRoot = [System.IO.Path]::GetFullPath($OutputDirectory) $targetRoot = Join-Path $cargoTargetRoot $Configuration $stage = Join-Path $outputRoot ".stage-$Version-$PID" -$zipPath = Join-Path $outputRoot "$PackageBrand-v$Version-windows-x64.zip" -$temporaryZip = Join-Path $outputRoot ".$PackageBrand-v$Version-windows-x64-$PID.tmp.zip" +$zipPath = Join-Path $outputRoot "$PackageBrand-v$Version-windows-$Architecture.zip" +$temporaryZip = Join-Path $outputRoot ".$PackageBrand-v$Version-windows-$Architecture-$PID.tmp.zip" $manifest = [ordered]@{ 'pebrel.exe' = Join-Path $targetRoot 'pebrel.exe' @@ -155,6 +158,8 @@ if ($missing.Count -ne 0) { } $packagedExe = $manifest['pebrel.exe'] +. (Join-Path $PSScriptRoot 'windows-package-architecture.ps1') +Assert-WindowsPackageArchitecture -Root $targetRoot -Architecture $Architecture Assert-FreshBinaries if ($PackageBrand -eq 'Pebrel' -and (Get-Item -LiteralPath $packagedExe).VersionInfo.ProductName -ne 'Pebrel') { throw 'Pebrel packages require a freshly built Pebrel executable, not renamed Nebula binaries.' diff --git a/scripts/preview_release.py b/scripts/preview_release.py index ac8f070..897ee01 100644 --- a/scripts/preview_release.py +++ b/scripts/preview_release.py @@ -47,7 +47,7 @@ def verify_binary_freshness(binary: Path, repo: Path) -> None: raise ManifestError(f"binary is older than {stale.relative_to(repo)}; rebuild GPUI before packaging") -def validate_evidence(directory: Path, commit: str) -> None: +def validate_evidence(directory: Path, commit: str, *, windows_arm64: bool = False) -> None: root = str(Path(__file__).resolve().parents[1]) if root not in sys.path: sys.path.insert(0, root) @@ -55,7 +55,10 @@ def validate_evidence(directory: Path, commit: str) -> None: golden = Path(__file__).resolve().parent / "conformance/golden" reports = [] - for name, platform in RUNTIME_REPORTS.items(): + required_reports = dict(RUNTIME_REPORTS) + if windows_arm64: + required_reports["windows-arm64-report.json"] = "windows-aarch64" + for name, platform in required_reports.items(): report = json.loads((directory / name).read_text(encoding="utf-8")) if report.get("platform") != platform or report.get("build", {}).get("commit") != commit: raise ManifestError(f"wrong platform or source commit in {name}") diff --git a/scripts/stable_release.py b/scripts/stable_release.py index 4ef9927..1dc63f2 100644 --- a/scripts/stable_release.py +++ b/scripts/stable_release.py @@ -43,6 +43,8 @@ def expected_asset_names(version: str) -> tuple[str, ...]: f"Pebrel-v{version}-windows-x64.zip", f"Pebrel-v{version}-windows-x64-setup.exe", ) + if tuple(map(int, version.split("."))) >= (1, 9, 0): + names += (f"Pebrel-v{version}-windows-arm64.zip",) # The old-name installer was retired from 1.7.0; retain historical manifests. if tuple(map(int, version.split("."))) < (1, 7, 0): names += (f"NebulaTerminal-{version}-windows-x64-setup.exe",) diff --git a/scripts/tests/package-release.tests.ps1 b/scripts/tests/package-release.tests.ps1 index 615aff3..cfbcd6b 100644 --- a/scripts/tests/package-release.tests.ps1 +++ b/scripts/tests/package-release.tests.ps1 @@ -42,6 +42,7 @@ try { throw 'Release packaging must use the shared explicit product build.' } & (Join-Path $PSScriptRoot 'build-windows-product.tests.ps1') + & (Join-Path $PSScriptRoot 'windows-package-architecture.tests.ps1') if ($scriptBody -notmatch 'Assert-FreshBinaries') { throw 'Release packaging must refuse stale binaries (freshness guard missing).' diff --git a/scripts/tests/test_preview_release.py b/scripts/tests/test_preview_release.py index 7416c8a..9ec4142 100644 --- a/scripts/tests/test_preview_release.py +++ b/scripts/tests/test_preview_release.py @@ -202,6 +202,16 @@ class PreviewReleaseTests(unittest.TestCase): "screenshot": "captured", "rendered_text": True, }), encoding="utf-8") validate_evidence(root, "a" * 40) + with self.assertRaises(FileNotFoundError): + validate_evidence(root, "a" * 40, windows_arm64=True) + arm_report = root / "windows-arm64-report.json" + report["platform"] = "windows-aarch64" + arm_report.write_text(json.dumps(report), encoding="utf-8") + validate_evidence(root, "a" * 40, windows_arm64=True) + report["platform"] = "windows-x86_64" + arm_report.write_text(json.dumps(report), encoding="utf-8") + with self.assertRaisesRegex(ManifestError, "wrong platform"): + validate_evidence(root, "a" * 40, windows_arm64=True) with self.assertRaisesRegex(ManifestError, "source commit"): validate_evidence(root, "b" * 40) launch_path = root / "macos-aarch64-launch.json" diff --git a/scripts/tests/test_stable_release.py b/scripts/tests/test_stable_release.py index c9dde21..a98796e 100644 --- a/scripts/tests/test_stable_release.py +++ b/scripts/tests/test_stable_release.py @@ -83,7 +83,14 @@ class StableReleaseTests(unittest.TestCase): self.assertNotIn("continue-on-error", shared) self.assertNotIn("continue-on-error", native) aggregate = workflow.split("\n aggregate:\n", 1)[1].split("\n publish:\n", 1)[0] - self.assertIn("needs: [prepare, native-tests, linux, macos, windows]", aggregate) + self.assertIn("needs: [prepare, native-tests, linux, macos, windows, windows-arm64]", aggregate) + self.assertIn("windows_arm64=True", aggregate) + arm = workflow.split("\n windows-arm64:\n", 1)[1].split("\n aggregate:\n", 1)[0] + for required in ("runs-on: windows-11-arm", "host: aarch64-pc-windows-msvc", + "-Architecture arm64", "windows-arm64-report.json", + "scripts/build-windows-product.ps1", "--platform windows-aarch64"): + self.assertIn(required, arm) + self.assertNotIn("continue-on-error", arm) self.assertNotIn("always()", aggregate) def test_native_packagers_expose_stable_channel_without_preview_id(self) -> None: @@ -115,10 +122,22 @@ class StableReleaseTests(unittest.TestCase): for version in ("1.7.0", "1.7.1", "1.10.0", "2.0.0"): with self.subTest(version=version): names = expected_asset_names(version) - self.assertEqual(len(names), 7) + self.assertEqual(len(names), 8 if tuple(map(int, version.split('.'))) >= (1, 9, 0) else 7) self.assertIn(f"Pebrel-v{version}-windows-x64-setup.exe", names) self.assertNotIn(f"NebulaTerminal-{version}-windows-x64-setup.exe", names) + def test_19_requires_native_windows_arm64_without_changing_historical_assets(self) -> None: + self.assertNotIn("Pebrel-v1.8.2-windows-arm64.zip", expected_asset_names("1.8.2")) + version = "1.9.0" + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for name in expected_asset_names(version): + write_fake_asset(root / name) + self.assertEqual(len(validate_assets(root, version)), 8) + (root / "Pebrel-v1.9.0-windows-arm64.zip").unlink() + with self.assertRaisesRegex(StableReleaseError, "missing: Pebrel-v1.9.0-windows-arm64.zip"): + validate_assets(root, version) + def test_17_assets_reject_retired_alias_and_missing_installer(self) -> None: version = "1.7.0" with tempfile.TemporaryDirectory() as directory: diff --git a/scripts/tests/windows-package-architecture.tests.ps1 b/scripts/tests/windows-package-architecture.tests.ps1 new file mode 100644 index 0000000..bd44e52 --- /dev/null +++ b/scripts/tests/windows-package-architecture.tests.ps1 @@ -0,0 +1,42 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +. (Join-Path $PSScriptRoot '../windows-package-architecture.ps1') +$fixtureRoot = Join-Path ([IO.Path]::GetTempPath()) ("pebrel-package-architecture-" + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $fixtureRoot | Out-Null +try { + foreach ($architecture in @('x64', 'arm64')) { + $bytes = New-Object byte[] 96 + [BitConverter]::GetBytes([uint16]0x5A4D).CopyTo($bytes, 0) + [BitConverter]::GetBytes([uint32]64).CopyTo($bytes, 0x3C) + [BitConverter]::GetBytes([uint32]0x4550).CopyTo($bytes, 64) + $machine = if ($architecture -eq 'arm64') { 0xAA64 } else { 0x8664 } + [BitConverter]::GetBytes([uint16]$machine).CopyTo($bytes, 68) + foreach ($name in @('pebrel.exe', 'pebrel-hook.exe', 'conpty.dll', 'OpenConsole.exe')) { + [IO.File]::WriteAllBytes((Join-Path $fixtureRoot $name), $bytes) + } + Assert-WindowsPackageArchitecture -Root $fixtureRoot -Architecture $architecture + $wrong = if ($architecture -eq 'arm64') { 'x64' } else { 'arm64' } + $rejected = $false + try { Assert-WindowsPackageArchitecture -Root $fixtureRoot -Architecture $wrong } catch { + $rejected = $_.Exception.Message -match 'architecture does not match' + } + if (-not $rejected) { throw 'Mislabeled machine type was accepted' } + # The application alone is insufficient: the packaged hook and console runtime must match. + $bytes[68] = 0 + [IO.File]::WriteAllBytes((Join-Path $fixtureRoot 'pebrel-hook.exe'), $bytes) + $rejected = $false + try { Assert-WindowsPackageArchitecture -Root $fixtureRoot -Architecture $architecture } catch { + $rejected = $_.Exception.Message -match 'pebrel-hook.exe' + } + if (-not $rejected) { throw 'Mismatched hook helper was accepted' } + [IO.File]::WriteAllBytes((Join-Path $fixtureRoot 'pebrel.exe'), [byte[]]@(0, 1, 2)) + $rejected = $false + try { Assert-WindowsPackageArchitecture -Root $fixtureRoot -Architecture $architecture } catch { + $rejected = $_.Exception.Message -match 'Invalid executable header' + } + if (-not $rejected) { throw 'Truncated executable was accepted' } + } + Write-Output 'windows-package-architecture.tests.ps1: PASS' +} finally { + Remove-Item -LiteralPath $fixtureRoot -Recurse -Force +} diff --git a/scripts/windows-package-architecture.ps1 b/scripts/windows-package-architecture.ps1 new file mode 100644 index 0000000..a4f401d --- /dev/null +++ b/scripts/windows-package-architecture.ps1 @@ -0,0 +1,30 @@ +# Validate the machine type of every executable payload before naming a Windows package. +function Assert-WindowsPackageArchitecture { + param( + [Parameter(Mandatory = $true)][string] $Root, + [ValidateSet('x64', 'arm64')][string] $Architecture = 'x64' + ) + $machine = if ($Architecture -eq 'arm64') { 0xAA64 } else { 0x8664 } + foreach ($name in @('pebrel.exe', 'pebrel-hook.exe', 'conpty.dll', 'OpenConsole.exe')) { + $path = Join-Path $Root $name + $stream = [IO.File]::OpenRead($path) + $reader = [IO.BinaryReader]::new($stream) + try { + if ($stream.Length -lt 64 -or $reader.ReadUInt16() -ne 0x5A4D) { + throw "Invalid executable header: $path" + } + $stream.Position = 0x3C + $offset = $reader.ReadUInt32() + if ($offset -lt 64 -or $offset -gt $stream.Length - 6) { + throw "Invalid executable header offset: $path" + } + $stream.Position = $offset + if ($reader.ReadUInt32() -ne 0x4550 -or $reader.ReadUInt16() -ne $machine) { + throw "Executable architecture does not match $Architecture`: $path" + } + } finally { + $reader.Dispose() + $stream.Dispose() + } + } +} From f4a42a878a4e053172ced01c26b2961eddf1656d Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 21 Sep 2026 23:10:21 +0800 Subject: [PATCH 03/20] test: separate agent installation and PATH fixtures on Unix --- nebula_app/src/platform/agent_integrations.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nebula_app/src/platform/agent_integrations.rs b/nebula_app/src/platform/agent_integrations.rs index 01d5f92..6616ac7 100644 --- a/nebula_app/src/platform/agent_integrations.rs +++ b/nebula_app/src/platform/agent_integrations.rs @@ -189,7 +189,8 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let grok_dir = dir.path().join(".grok/bin"); let grok = executable(&grok_dir, "agent"); - let cursor_dir = dir.path().join("cursor-agent/versions/current"); + // Keep the installation directory distinct from the extensionless PATH command on Unix. + let cursor_dir = dir.path().join("installed/cursor-agent/versions/current"); let cursor = executable(&cursor_dir, "agent"); let dirs = vec![grok_dir, cursor_dir]; assert_eq!(find_executable(AgentKind::Cursor, &dirs), Some(cursor)); From edada80616a2de21ba4303e60cdea066d05f1adf Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 21 Sep 2026 23:20:11 +0800 Subject: [PATCH 04/20] test: use native selection shortcuts on macOS --- .../src/gpui_shell/file_editor/code_actions_tests.rs | 5 ++++- nebula_app/src/gpui_shell/file_editor/tests.rs | 10 +++++++--- .../src/gpui_shell/workspace/details_panel_tests.rs | 8 ++++++-- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/nebula_app/src/gpui_shell/file_editor/code_actions_tests.rs b/nebula_app/src/gpui_shell/file_editor/code_actions_tests.rs index 24169e3..8fee28b 100644 --- a/nebula_app/src/gpui_shell/file_editor/code_actions_tests.rs +++ b/nebula_app/src/gpui_shell/file_editor/code_actions_tests.rs @@ -55,7 +55,10 @@ fn read_only_language_picker_changes_highlighting_without_editing_source(cx: &mu let picker = cx.debug_bounds("markdown-language-picker").unwrap(); cx.simulate_click(picker.center(), Modifiers::default()); cx.run_until_parked(); - cx.simulate_keystrokes("ctrl-a"); + cx.simulate_keystrokes(match crate::platform::Platform::current() { + crate::platform::Platform::MacOS => "cmd-a", + _ => "ctrl-a", + }); cx.simulate_input(choice); cx.run_until_parked(); press("enter", &mut cx); diff --git a/nebula_app/src/gpui_shell/file_editor/tests.rs b/nebula_app/src/gpui_shell/file_editor/tests.rs index 58906a7..9058ac7 100644 --- a/nebula_app/src/gpui_shell/file_editor/tests.rs +++ b/nebula_app/src/gpui_shell/file_editor/tests.rs @@ -103,17 +103,21 @@ fn returning_to_reader_keeps_source_edits_and_blocks_undo_until_source_is_open( std::fs::write(&path, source).unwrap(); let (file, mut cx) = open(path, cx); cx.update(|window, cx| file.read(cx).focus.clone().focus(window, cx)); - cx.simulate_keystrokes("ctrl-/ ctrl-a"); + let modifier = match crate::platform::Platform::current() { + crate::platform::Platform::MacOS => "cmd", + _ => "ctrl", + }; + cx.simulate_keystrokes(&format!("{modifier}-/ {modifier}-a")); cx.simulate_input("Updated"); cx.run_until_parked(); - cx.simulate_keystrokes("ctrl-/ ctrl-z"); + cx.simulate_keystrokes(&format!("{modifier}-/ {modifier}-z")); cx.run_until_parked(); file.read_with(&cx, |view, cx| { assert!(view.preview && !view.live_mode); assert_eq!(view.draft(cx), "Updated"); assert!(view.dirty); }); - cx.simulate_keystrokes("ctrl-/ ctrl-z"); + cx.simulate_keystrokes(&format!("{modifier}-/ {modifier}-z")); cx.run_until_parked(); assert_eq!(file.read_with(&cx, |view, cx| view.draft(cx)), source); } diff --git a/nebula_app/src/gpui_shell/workspace/details_panel_tests.rs b/nebula_app/src/gpui_shell/workspace/details_panel_tests.rs index c679fe2..2aa9e10 100644 --- a/nebula_app/src/gpui_shell/workspace/details_panel_tests.rs +++ b/nebula_app/src/gpui_shell/workspace/details_panel_tests.rs @@ -53,6 +53,10 @@ fn width_limits_keep_room_for_the_document_without_losing_preference() { fn file_path_edit_navigates_and_keeps_the_last_directory_on_error_or_escape( cx: &mut TestAppContext, ) { + let select_all = match crate::platform::Platform::current() { + crate::platform::Platform::MacOS => "cmd-a", + _ => "ctrl-a", + }; let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("sidebar.md"); let target = directory.path().join("中文 folder"); @@ -70,7 +74,7 @@ fn file_path_edit_navigates_and_keeps_the_last_directory_on_error_or_escape( cx.update(|window, cx| window.draw(cx).clear(cx)); let path_button = cx.debug_bounds("file-tree-path").unwrap(); cx.simulate_click(path_button.center(), Modifiers::default()); - cx.simulate_keystrokes("ctrl-a"); + cx.simulate_keystrokes(select_all); cx.simulate_input("中文 folder"); cx.simulate_keystrokes("enter"); cx.run_until_parked(); @@ -103,7 +107,7 @@ fn file_path_edit_navigates_and_keeps_the_last_directory_on_error_or_escape( cx.update(|window, cx| window.draw(cx).clear(cx)); let path_button = cx.debug_bounds("file-tree-path").unwrap(); cx.simulate_click(path_button.center(), Modifiers::default()); - cx.simulate_keystrokes("ctrl-a"); + cx.simulate_keystrokes(select_all); cx.simulate_input("missing-directory"); cx.simulate_keystrokes("enter"); cx.run_until_parked(); From be63659b9d24619cea4a25a896a0acfcc4793515 Mon Sep 17 00:00:00 2001 From: kuddev Date: Tue, 22 Sep 2026 11:06:12 +0800 Subject: [PATCH 05/20] ci: diagnose Windows ARM64 conformance and key the platform golden by family --- .../workflows/windows-arm64-diagnostics.yml | 81 +++++++ scripts/conformance/conpty_smoke.py | 218 ++++++++++++++++++ scripts/conformance/golden/windows.json | 2 +- scripts/conformance/harness.py | 5 +- scripts/conformance/tests/test_harness.py | 24 ++ 5 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/windows-arm64-diagnostics.yml create mode 100644 scripts/conformance/conpty_smoke.py diff --git a/.github/workflows/windows-arm64-diagnostics.yml b/.github/workflows/windows-arm64-diagnostics.yml new file mode 100644 index 0000000..74716ee --- /dev/null +++ b/.github/workflows/windows-arm64-diagnostics.yml @@ -0,0 +1,81 @@ +name: Windows ARM64 conformance diagnostics + +on: + workflow_dispatch: + inputs: + package_run: + description: Release run whose stable-package-windows-arm64 artifact should be diagnosed + required: true + type: string + +permissions: + contents: read + actions: read + +jobs: + diagnose: + runs-on: windows-11-arm + timeout-minutes: 20 + env: + PYTHONUTF8: "1" + NEBULA_RESIZE_TRACE: "1" + NEBULA_BOOT_TRACE: "1" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - uses: actions/download-artifact@v4 + with: + name: stable-package-windows-arm64 + run-id: ${{ inputs.package_run }} + github-token: ${{ github.token }} + path: diagnostic-package + - name: Extract the portable package + shell: pwsh + run: | + $zip = Get-ChildItem diagnostic-package -Filter *.zip | Select-Object -First 1 + Expand-Archive -LiteralPath $zip.FullName -DestinationPath diagnostic-runtime -Force + Get-ChildItem -Recurse diagnostic-runtime | Select-Object FullName, Length + - name: Describe the host + shell: pwsh + run: | + "PROCESSOR_ARCHITECTURE=$env:PROCESSOR_ARCHITECTURE" + [System.Runtime.InteropServices.RuntimeInformation]::OSDescription + [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture + whoami /groups | Select-String -Pattern 'Mandatory|Administrators' + foreach ($name in 'pwsh', 'powershell', 'cmd', 'python') { + $command = Get-Command $name -ErrorAction SilentlyContinue + if ($null -eq $command) { "$name: not found"; continue } + $bytes = [IO.File]::ReadAllBytes($command.Source) + $offset = [BitConverter]::ToInt32($bytes, 0x3C) + $machine = [BitConverter]::ToUInt16($bytes, $offset + 4) + "{0}: {1} machine={2:x4}" -f $name, $command.Source, $machine + } + python -c "import platform, sys; print(sys.version, platform.machine())" + - name: ConPTY smoke with an ordinary token + if: always() + shell: pwsh + run: python scripts/conformance/windows_standard_user.py scripts/conformance/conpty_smoke.py --runtime diagnostic-runtime/runtime --output diagnostic-output/smoke-ordinary.json + - name: ConPTY smoke with the runner token + if: always() + shell: pwsh + run: python scripts/conformance/conpty_smoke.py --runtime diagnostic-runtime/runtime --output diagnostic-output/smoke-runner.json + - name: Conformance with the side-loaded runtime + if: always() + shell: pwsh + run: python scripts/conformance/windows_standard_user.py scripts/conformance/run.py --app diagnostic-runtime/pebrel.exe --platform windows-aarch64 --output diagnostic-output/sideload/windows-arm64-report.json --no-platform-golden + - name: Conformance with the in-box ConPTY + if: always() + shell: pwsh + run: | + Copy-Item -Recurse diagnostic-runtime diagnostic-runtime-inbox + Remove-Item diagnostic-runtime-inbox/runtime/conpty.dll, diagnostic-runtime-inbox/runtime/OpenConsole.exe + python scripts/conformance/windows_standard_user.py scripts/conformance/run.py --app diagnostic-runtime-inbox/pebrel.exe --platform windows-aarch64 --output diagnostic-output/inbox/windows-arm64-report.json --no-platform-golden + - uses: actions/upload-artifact@v4 + if: always() + with: + name: windows-arm64-conformance-diagnostics + path: diagnostic-output + if-no-files-found: warn + retention-days: 7 diff --git a/scripts/conformance/conpty_smoke.py b/scripts/conformance/conpty_smoke.py new file mode 100644 index 0000000..cfad172 --- /dev/null +++ b/scripts/conformance/conpty_smoke.py @@ -0,0 +1,218 @@ +"""Spawn a shell through ConPTY without Pebrel and report its first output. + +Separates runtime failures (conpty.dll, OpenConsole.exe, the shell itself) +from application failures when a platform renders no initial shell output. +Both the side-loaded pair and the in-box kernel32 ConPTY are exercised. +""" + +from __future__ import annotations + +import argparse +import ctypes +from ctypes import wintypes as wt +import json +import os +import shutil +import sys +import threading +import time + +PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE = 0x00020016 +EXTENDED_STARTUPINFO_PRESENT = 0x00080000 +STARTF_USESTDHANDLES = 0x00000100 +PSEUDOCONSOLE_WIN32_INPUT_MODE = 0x4 + + +class Coord(ctypes.Structure): + _fields_ = [("X", ctypes.c_short), ("Y", ctypes.c_short)] + + +class StartupInfo(ctypes.Structure): + _fields_ = [ + ("cb", wt.DWORD), ("lpReserved", wt.LPWSTR), ("lpDesktop", wt.LPWSTR), + ("lpTitle", wt.LPWSTR), ("dwX", wt.DWORD), ("dwY", wt.DWORD), + ("dwXSize", wt.DWORD), ("dwYSize", wt.DWORD), ("dwXCountChars", wt.DWORD), + ("dwYCountChars", wt.DWORD), ("dwFillAttribute", wt.DWORD), + ("dwFlags", wt.DWORD), ("wShowWindow", wt.WORD), ("cbReserved2", wt.WORD), + ("lpReserved2", ctypes.POINTER(wt.BYTE)), ("hStdInput", wt.HANDLE), + ("hStdOutput", wt.HANDLE), ("hStdError", wt.HANDLE), + ] + + +class StartupInfoEx(ctypes.Structure): + _fields_ = [("StartupInfo", StartupInfo), ("lpAttributeList", wt.LPVOID)] + + +class ProcessInfo(ctypes.Structure): + _fields_ = [("hProcess", wt.HANDLE), ("hThread", wt.HANDLE), + ("dwProcessId", wt.DWORD), ("dwThreadId", wt.DWORD)] + + +kernel = ctypes.WinDLL("kernel32", use_last_error=True) +kernel.CreatePipe.argtypes = [ctypes.POINTER(wt.HANDLE), ctypes.POINTER(wt.HANDLE), wt.LPVOID, wt.DWORD] +kernel.CreatePipe.restype = wt.BOOL +kernel.ReadFile.argtypes = [wt.HANDLE, wt.LPVOID, wt.DWORD, ctypes.POINTER(wt.DWORD), wt.LPVOID] +kernel.ReadFile.restype = wt.BOOL +kernel.WriteFile.argtypes = [wt.HANDLE, wt.LPCVOID, wt.DWORD, ctypes.POINTER(wt.DWORD), wt.LPVOID] +kernel.WriteFile.restype = wt.BOOL +kernel.CloseHandle.argtypes = [wt.HANDLE] +kernel.CloseHandle.restype = wt.BOOL +kernel.InitializeProcThreadAttributeList.argtypes = [wt.LPVOID, wt.DWORD, wt.DWORD, ctypes.POINTER(ctypes.c_size_t)] +kernel.InitializeProcThreadAttributeList.restype = wt.BOOL +kernel.UpdateProcThreadAttribute.argtypes = [wt.LPVOID, wt.DWORD, ctypes.c_size_t, wt.LPVOID, ctypes.c_size_t, wt.LPVOID, wt.LPVOID] +kernel.UpdateProcThreadAttribute.restype = wt.BOOL +kernel.DeleteProcThreadAttributeList.argtypes = [wt.LPVOID] +kernel.CreateProcessW.argtypes = [wt.LPCWSTR, wt.LPWSTR, wt.LPVOID, wt.LPVOID, wt.BOOL, wt.DWORD, wt.LPVOID, wt.LPCWSTR, wt.LPVOID, ctypes.POINTER(ProcessInfo)] +kernel.CreateProcessW.restype = wt.BOOL +kernel.WaitForSingleObject.argtypes = [wt.HANDLE, wt.DWORD] +kernel.WaitForSingleObject.restype = wt.DWORD +kernel.GetExitCodeProcess.argtypes = [wt.HANDLE, ctypes.POINTER(wt.DWORD)] +kernel.GetExitCodeProcess.restype = wt.BOOL +kernel.TerminateProcess.argtypes = [wt.HANDLE, wt.UINT] +kernel.TerminateProcess.restype = wt.BOOL + +CreateFn = ctypes.WINFUNCTYPE(ctypes.c_long, Coord, wt.HANDLE, wt.HANDLE, wt.DWORD, ctypes.POINTER(wt.LPVOID)) +CloseFn = ctypes.WINFUNCTYPE(None, wt.LPVOID) + + +def api(library: str | None) -> tuple[CreateFn, CloseFn, str]: + module = ctypes.WinDLL(library, use_last_error=True) if library else kernel + create = CreateFn(("CreatePseudoConsole", module)) + close = CloseFn(("ClosePseudoConsole", module)) + return create, close, library or "kernel32" + + +def run_case(library: str | None, command: str, flags: int, wait_seconds: float) -> dict[str, object]: + result: dict[str, object] = {"library": library or "kernel32", "command": command, "flags": flags} + try: + create, close, _ = api(library) + except OSError as error: + result["error"] = f"load failed: {error}" + return result + conout_read, conout_write = wt.HANDLE(), wt.HANDLE() + conin_read, conin_write = wt.HANDLE(), wt.HANDLE() + if not kernel.CreatePipe(ctypes.byref(conout_read), ctypes.byref(conout_write), None, 0) or \ + not kernel.CreatePipe(ctypes.byref(conin_read), ctypes.byref(conin_write), None, 0): + result["error"] = f"CreatePipe failed: {ctypes.get_last_error()}" + return result + hpc = wt.LPVOID() + started = time.monotonic() + hresult = create(Coord(80, 25), conin_read, conout_write, flags, ctypes.byref(hpc)) + result["create_hresult"] = f"{hresult & 0xFFFFFFFF:#010x}" + result["create_ms"] = round((time.monotonic() - started) * 1000) + if hresult != 0: + return result + # The side-loaded host answers the same primed DA1 reply Pebrel writes. + written = wt.DWORD() + kernel.WriteFile(conin_write, b"\x1b[?61c", 6, ctypes.byref(written), None) + + size = ctypes.c_size_t(0) + kernel.InitializeProcThreadAttributeList(None, 1, 0, ctypes.byref(size)) + attributes = ctypes.create_string_buffer(size.value) + if not kernel.InitializeProcThreadAttributeList(attributes, 1, 0, ctypes.byref(size)): + result["error"] = f"InitializeProcThreadAttributeList failed: {ctypes.get_last_error()}" + return result + if not kernel.UpdateProcThreadAttribute(attributes, 0, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, + hpc, ctypes.sizeof(wt.LPVOID), None, None): + result["error"] = f"UpdateProcThreadAttribute failed: {ctypes.get_last_error()}" + return result + startup = StartupInfoEx() + startup.StartupInfo.cb = ctypes.sizeof(startup) + # Null standard handles with USESTDHANDLES: the client binds to the + # pseudoconsole instead of inheriting this process's console or pipes. + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES + startup.lpAttributeList = ctypes.cast(attributes, wt.LPVOID) + process = ProcessInfo() + cmdline = ctypes.create_unicode_buffer(command) + if not kernel.CreateProcessW(None, cmdline, None, None, False, EXTENDED_STARTUPINFO_PRESENT, + None, None, ctypes.byref(startup), ctypes.byref(process)): + result["error"] = f"CreateProcessW failed: {ctypes.get_last_error()}" + return result + result["pid"] = process.dwProcessId + + chunks: list[bytes] = [] + + def reader() -> None: + buffer = ctypes.create_string_buffer(4096) + read = wt.DWORD() + while kernel.ReadFile(conout_read, buffer, 4096, ctypes.byref(read), None) and read.value: + chunks.append(buffer.raw[: read.value]) + + thread = threading.Thread(target=reader, daemon=True) + thread.start() + deadline = time.monotonic() + wait_seconds + first_output_ms: int | None = None + while time.monotonic() < deadline: + if chunks and first_output_ms is None: + first_output_ms = round((time.monotonic() - started) * 1000) + if kernel.WaitForSingleObject(process.hProcess, 100) == 0: + break + code = wt.DWORD() + kernel.GetExitCodeProcess(process.hProcess, ctypes.byref(code)) + if code.value == 259: # STILL_ACTIVE + kernel.TerminateProcess(process.hProcess, 1) + result["exit"] = "terminated after timeout" + else: + result["exit"] = code.value + # The in-box host flushes trailing output while ClosePseudoConsole drains + # the pipe; the reader thread keeps consuming during that call. + kernel.CloseHandle(conout_write) + close(hpc) + thread.join(2.0) + output = b"".join(chunks) + if chunks and first_output_ms is None: + first_output_ms = round((time.monotonic() - started) * 1000) + result["first_output_ms"] = first_output_ms + result["output_bytes"] = len(output) + result["output_head"] = output[:400].decode("utf-8", "replace") + kernel.CloseHandle(process.hThread) + kernel.CloseHandle(process.hProcess) + kernel.DeleteProcThreadAttributeList(attributes) + for handle in (conin_write, conin_read, conout_read): + kernel.CloseHandle(handle) + return result + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--runtime", help="directory holding conpty.dll and OpenConsole.exe") + parser.add_argument("--wait", type=float, default=15.0) + parser.add_argument("--output") + args = parser.parse_args() + + shells = [] + for name in ("pwsh", "powershell", "cmd"): + path = shutil.which(name) + if path: + shells.append((name, path)) + libraries: list[str | None] = [None] + if args.runtime: + libraries.insert(0, os.path.join(os.path.abspath(args.runtime), "conpty.dll")) + + report: dict[str, object] = { + "architecture": os.environ.get("PROCESSOR_ARCHITECTURE"), + "python": sys.version, + "shells": dict(shells), + "cases": [], + } + commands = { + "pwsh": '"{path}" -NoLogo -NoProfile -Command "Write-Output CONPTY_SMOKE_OK"', + "powershell": '"{path}" -NoLogo -NoProfile -Command "Write-Output CONPTY_SMOKE_OK"', + "cmd": '"{path}" /d /c "echo CONPTY_SMOKE_OK"', + } + for library in libraries: + for name, path in shells: + for flags in (PSEUDOCONSOLE_WIN32_INPUT_MODE, 0): + case = run_case(library, commands[name].format(path=path), flags, args.wait) + case["shell"] = name + print(json.dumps(case, ensure_ascii=False), flush=True) + report["cases"].append(case) + if args.output: + os.makedirs(os.path.dirname(os.path.abspath(args.output)), exist_ok=True) + with open(args.output, "w", encoding="utf-8") as handle: + json.dump(report, handle, ensure_ascii=False, indent=2) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/conformance/golden/windows.json b/scripts/conformance/golden/windows.json index 84beaec..95b1267 100644 --- a/scripts/conformance/golden/windows.json +++ b/scripts/conformance/golden/windows.json @@ -36,7 +36,7 @@ "cases.split.status": "passed", "cases.ssh_loop.reason": "runtime_api_has_no_ssh_open_method", "cases.ssh_loop.status": "skipped", - "platform": "windows-x86_64", + "platform": "windows", "schema_version": 1, "summary.failed": 0, "summary.passed": 9, diff --git a/scripts/conformance/harness.py b/scripts/conformance/harness.py index 92da174..a88a6e1 100644 --- a/scripts/conformance/harness.py +++ b/scripts/conformance/harness.py @@ -936,7 +936,10 @@ def load_rules(golden_dir: Path) -> dict[str, Any]: def stable_flat(report: dict[str, Any], golden_dir: Path) -> dict[str, Any]: rules = load_rules(golden_dir) - return filter_flat(flatten(report), list(rules.get("volatile", {}))) + flat = filter_flat(flatten(report), list(rules.get("volatile", {}))) + # One golden per family serves every architecture of that platform. + flat["platform"] = platform_family(str(report["platform"])) + return flat def compare_platform_golden(report: dict[str, Any], golden_dir: Path) -> list[str]: diff --git a/scripts/conformance/tests/test_harness.py b/scripts/conformance/tests/test_harness.py index 0bddd08..4487a88 100644 --- a/scripts/conformance/tests/test_harness.py +++ b/scripts/conformance/tests/test_harness.py @@ -154,6 +154,30 @@ class ComparisonTests(unittest.TestCase): errors = compare_reports([first, second], golden) self.assertTrue(any("tail_contains_marker" in error for error in errors)) + def test_platform_golden_serves_every_architecture_of_a_family(self) -> None: + from conformance.harness import compare_platform_golden, stable_flat + + report = { + "schema_version": 1, + "platform": "windows-x86_64", + "cases": {"boot": {"status": "passed", "duration_ms": 3}}, + "summary": {"total": 1, "passed": 1, "failed": 0, "skipped": 0}, + } + with tempfile.TemporaryDirectory() as directory: + golden = Path(directory) + (golden / "whitelist.json").write_text( + json.dumps({"volatile": {"cases.*.duration_ms": "timing"}, "cross_platform": {}}), + encoding="utf-8", + ) + expected = stable_flat(report, golden) + self.assertEqual(expected["platform"], "windows") + (golden / "windows.json").write_text(json.dumps(expected), encoding="utf-8") + arm64 = json.loads(json.dumps(report)) + arm64["platform"] = "windows-aarch64" + self.assertEqual(compare_platform_golden(arm64, golden), []) + arm64["cases"]["boot"]["status"] = "failed" + self.assertTrue(compare_platform_golden(arm64, golden)) + class ArchiveSafetyTests(unittest.TestCase): def test_new_portable_package_resolves_only_the_product_executable(self) -> None: From 1bc4950566e9bbf2f87e0db19424e13f4180a271 Mon Sep 17 00:00:00 2001 From: kuddev Date: Tue, 22 Sep 2026 11:09:41 +0800 Subject: [PATCH 06/20] ci: run the ARM64 diagnostics on branch pushes --- .github/workflows/windows-arm64-diagnostics.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/windows-arm64-diagnostics.yml b/.github/workflows/windows-arm64-diagnostics.yml index 74716ee..6f9abec 100644 --- a/.github/workflows/windows-arm64-diagnostics.yml +++ b/.github/workflows/windows-arm64-diagnostics.yml @@ -7,11 +7,21 @@ on: description: Release run whose stable-package-windows-arm64 artifact should be diagnosed required: true type: string + push: + branches: [release/1.9.0-final] + paths: + - .github/workflows/windows-arm64-diagnostics.yml + - scripts/conformance/conpty_smoke.py permissions: contents: read actions: read +env: + # Dispatch is only available once the workflow exists on the default branch; + # branch pushes diagnose the latest ARM64 package produced by the release run. + PACKAGE_RUN: ${{ inputs.package_run || '35618479129' }} + jobs: diagnose: runs-on: windows-11-arm @@ -28,7 +38,7 @@ jobs: - uses: actions/download-artifact@v4 with: name: stable-package-windows-arm64 - run-id: ${{ inputs.package_run }} + run-id: ${{ env.PACKAGE_RUN }} github-token: ${{ github.token }} path: diagnostic-package - name: Extract the portable package From cd0f4a645016c899479fe631e78d66bd930068f7 Mon Sep 17 00:00:00 2001 From: kuddev Date: Tue, 22 Sep 2026 11:47:48 +0800 Subject: [PATCH 07/20] ci: probe the interactive PowerShell integration on ARM64 --- .../workflows/windows-arm64-diagnostics.yml | 18 ++++----- scripts/conformance/conpty_smoke.py | 37 ++++++++++++++++++- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/.github/workflows/windows-arm64-diagnostics.yml b/.github/workflows/windows-arm64-diagnostics.yml index 6f9abec..daaf868 100644 --- a/.github/workflows/windows-arm64-diagnostics.yml +++ b/.github/workflows/windows-arm64-diagnostics.yml @@ -56,21 +56,13 @@ jobs: whoami /groups | Select-String -Pattern 'Mandatory|Administrators' foreach ($name in 'pwsh', 'powershell', 'cmd', 'python') { $command = Get-Command $name -ErrorAction SilentlyContinue - if ($null -eq $command) { "$name: not found"; continue } + if ($null -eq $command) { "${name}: not found"; continue } $bytes = [IO.File]::ReadAllBytes($command.Source) $offset = [BitConverter]::ToInt32($bytes, 0x3C) $machine = [BitConverter]::ToUInt16($bytes, $offset + 4) "{0}: {1} machine={2:x4}" -f $name, $command.Source, $machine } python -c "import platform, sys; print(sys.version, platform.machine())" - - name: ConPTY smoke with an ordinary token - if: always() - shell: pwsh - run: python scripts/conformance/windows_standard_user.py scripts/conformance/conpty_smoke.py --runtime diagnostic-runtime/runtime --output diagnostic-output/smoke-ordinary.json - - name: ConPTY smoke with the runner token - if: always() - shell: pwsh - run: python scripts/conformance/conpty_smoke.py --runtime diagnostic-runtime/runtime --output diagnostic-output/smoke-runner.json - name: Conformance with the side-loaded runtime if: always() shell: pwsh @@ -82,6 +74,14 @@ jobs: Copy-Item -Recurse diagnostic-runtime diagnostic-runtime-inbox Remove-Item diagnostic-runtime-inbox/runtime/conpty.dll, diagnostic-runtime-inbox/runtime/OpenConsole.exe python scripts/conformance/windows_standard_user.py scripts/conformance/run.py --app diagnostic-runtime-inbox/pebrel.exe --platform windows-aarch64 --output diagnostic-output/inbox/windows-arm64-report.json --no-platform-golden + - name: ConPTY smoke with an ordinary token + if: always() + shell: pwsh + run: python scripts/conformance/windows_standard_user.py scripts/conformance/conpty_smoke.py --runtime diagnostic-runtime/runtime --wait 25 --prompt-script "$env:TEMP/pebrel_prompt.ps1" --output diagnostic-output/smoke-ordinary.json + - name: ConPTY smoke with the runner token + if: always() + shell: pwsh + run: python scripts/conformance/conpty_smoke.py --runtime diagnostic-runtime/runtime --wait 25 --prompt-script "$env:TEMP/pebrel_prompt.ps1" --output diagnostic-output/smoke-runner.json - uses: actions/upload-artifact@v4 if: always() with: diff --git a/scripts/conformance/conpty_smoke.py b/scripts/conformance/conpty_smoke.py index cfad172..8b6f1ee 100644 --- a/scripts/conformance/conpty_smoke.py +++ b/scripts/conformance/conpty_smoke.py @@ -82,7 +82,14 @@ def api(library: str | None) -> tuple[CreateFn, CloseFn, str]: return create, close, library or "kernel32" -def run_case(library: str | None, command: str, flags: int, wait_seconds: float) -> dict[str, object]: +def run_case( + library: str | None, + command: str, + flags: int, + wait_seconds: float, + done_marker: bytes = b"CONPTY_SMOKE_OK", +) -> dict[str, object]: + """Interactive shells never exit; the marker ends the wait once it renders.""" result: dict[str, object] = {"library": library or "kernel32", "command": command, "flags": flags} try: create, close, _ = api(library) @@ -142,11 +149,16 @@ def run_case(library: str | None, command: str, flags: int, wait_seconds: float) thread.start() deadline = time.monotonic() + wait_seconds first_output_ms: int | None = None + marker_ms: int | None = None while time.monotonic() < deadline: if chunks and first_output_ms is None: first_output_ms = round((time.monotonic() - started) * 1000) + if marker_ms is None and done_marker in b"".join(chunks): + marker_ms = round((time.monotonic() - started) * 1000) + break if kernel.WaitForSingleObject(process.hProcess, 100) == 0: break + result["marker_ms"] = marker_ms code = wt.DWORD() kernel.GetExitCodeProcess(process.hProcess, ctypes.byref(code)) if code.value == 259: # STILL_ACTIVE @@ -178,7 +190,13 @@ def main() -> int: parser.add_argument("--runtime", help="directory holding conpty.dll and OpenConsole.exe") parser.add_argument("--wait", type=float, default=15.0) parser.add_argument("--output") + parser.add_argument( + "--prompt-script", + help="also start Windows PowerShell interactively with this integration script, like Pebrel does", + ) args = parser.parse_args() + # Terminal bytes include private-use glyphs that legacy code pages reject. + sys.stdout.reconfigure(encoding="utf-8", errors="replace") shells = [] for name in ("pwsh", "powershell", "cmd"): @@ -207,6 +225,23 @@ def main() -> int: case["shell"] = name print(json.dumps(case, ensure_ascii=False), flush=True) report["cases"].append(case) + powershell = dict(shells).get("powershell") + if args.prompt_script and powershell: + # The prompt function emits OSC 133;A once PowerShell reaches its + # first interactive prompt; a bare interactive shell shows "PS ". + script = os.path.abspath(args.prompt_script) + interactive = { + "powershell-interactive": (f'"{powershell}" -NoLogo -NoExit', b"PS "), + "powershell-integration": ( + f'"{powershell}" -NoLogo -NoExit -ExecutionPolicy Bypass -Command ". \'{script}\'"', + b"133;A", + ), + } + for name, (command, marker) in interactive.items(): + case = run_case(library, command, PSEUDOCONSOLE_WIN32_INPUT_MODE, args.wait, marker) + case["shell"] = name + print(json.dumps(case, ensure_ascii=False), flush=True) + report["cases"].append(case) if args.output: os.makedirs(os.path.dirname(os.path.abspath(args.output)), exist_ok=True) with open(args.output, "w", encoding="utf-8") as handle: From d3d2970a365cd491cf404e5db96b8a054063d1df Mon Sep 17 00:00:00 2001 From: kuddev Date: Tue, 22 Sep 2026 12:06:39 +0800 Subject: [PATCH 08/20] ci: replay resize and cursor probe in the ARM64 ConPTY smoke --- scripts/conformance/conpty_smoke.py | 79 +++++++++++++++++++++++++---- 1 file changed, 70 insertions(+), 9 deletions(-) diff --git a/scripts/conformance/conpty_smoke.py b/scripts/conformance/conpty_smoke.py index 8b6f1ee..94e679f 100644 --- a/scripts/conformance/conpty_smoke.py +++ b/scripts/conformance/conpty_smoke.py @@ -70,16 +70,51 @@ kernel.GetExitCodeProcess.argtypes = [wt.HANDLE, ctypes.POINTER(wt.DWORD)] kernel.GetExitCodeProcess.restype = wt.BOOL kernel.TerminateProcess.argtypes = [wt.HANDLE, wt.UINT] kernel.TerminateProcess.restype = wt.BOOL +kernel.FreeConsole.argtypes = [] +kernel.FreeConsole.restype = wt.BOOL +kernel.AttachConsole.argtypes = [wt.DWORD] +kernel.AttachConsole.restype = wt.BOOL +kernel.CreateFileW.argtypes = [wt.LPCWSTR, wt.DWORD, wt.DWORD, wt.LPVOID, wt.DWORD, wt.DWORD, wt.HANDLE] +kernel.CreateFileW.restype = wt.HANDLE +kernel.GetConsoleScreenBufferInfo.argtypes = [wt.HANDLE, wt.LPVOID] +kernel.GetConsoleScreenBufferInfo.restype = wt.BOOL +INVALID_HANDLE_VALUE = wt.HANDLE(-1).value + + +def cursor_probe(pid: int) -> dict[str, object]: + """The same conhost cursor probe Pebrel runs after every resize.""" + kernel.FreeConsole() + if not kernel.AttachConsole(pid): + return {"attach_error": ctypes.get_last_error()} + handle = kernel.CreateFileW("CONOUT$", 0xC0000000, 0x3, None, 3, 0, None) + if handle == INVALID_HANDLE_VALUE: + error = ctypes.get_last_error() + kernel.FreeConsole() + return {"conout_error": error} + info = ctypes.create_string_buffer(22) + ok = kernel.GetConsoleScreenBufferInfo(handle, info) + error = ctypes.get_last_error() + kernel.CloseHandle(handle) + kernel.FreeConsole() + if not ok: + return {"info_error": error} + # CONSOLE_SCREEN_BUFFER_INFO: dwSize, dwCursorPosition, wAttributes, srWindow. + cursor_y = int.from_bytes(info.raw[6:8], "little", signed=True) + top = int.from_bytes(info.raw[12:14], "little", signed=True) + bottom = int.from_bytes(info.raw[16:18], "little", signed=True) + return {"row": cursor_y - top, "rows": bottom - top + 1} CreateFn = ctypes.WINFUNCTYPE(ctypes.c_long, Coord, wt.HANDLE, wt.HANDLE, wt.DWORD, ctypes.POINTER(wt.LPVOID)) +ResizeFn = ctypes.WINFUNCTYPE(ctypes.c_long, wt.LPVOID, Coord) CloseFn = ctypes.WINFUNCTYPE(None, wt.LPVOID) -def api(library: str | None) -> tuple[CreateFn, CloseFn, str]: +def api(library: str | None) -> tuple[CreateFn, ResizeFn, CloseFn]: module = ctypes.WinDLL(library, use_last_error=True) if library else kernel create = CreateFn(("CreatePseudoConsole", module)) + resize = ResizeFn(("ResizePseudoConsole", module)) close = CloseFn(("ClosePseudoConsole", module)) - return create, close, library or "kernel32" + return create, resize, close def run_case( @@ -88,11 +123,19 @@ def run_case( flags: int, wait_seconds: float, done_marker: bytes = b"CONPTY_SMOKE_OK", + steps: tuple[str, ...] = (), ) -> dict[str, object]: - """Interactive shells never exit; the marker ends the wait once it renders.""" - result: dict[str, object] = {"library": library or "kernel32", "command": command, "flags": flags} + """Interactive shells never exit; the marker ends the wait once it renders. + + `steps` replays Pebrel's post-spawn sequence 30ms after the first output: + `resize` calls ResizePseudoConsole, `probe` attaches to the client console + and reads its cursor, mirroring the align-sync and align stages. + """ + result: dict[str, object] = { + "library": library or "kernel32", "command": command, "flags": flags, "steps": list(steps), + } try: - create, close, _ = api(library) + create, resize, close = api(library) except OSError as error: result["error"] = f"load failed: {error}" return result @@ -150,14 +193,28 @@ def run_case( deadline = time.monotonic() + wait_seconds first_output_ms: int | None = None marker_ms: int | None = None + steps_at: float | None = None + step_log: list[dict[str, object]] = [] while time.monotonic() < deadline: if chunks and first_output_ms is None: first_output_ms = round((time.monotonic() - started) * 1000) + steps_at = time.monotonic() + 0.03 + if steps and steps_at is not None and time.monotonic() >= steps_at: + for step in steps: + entry: dict[str, object] = {"step": step, "at_ms": round((time.monotonic() - started) * 1000)} + if step == "resize": + entry["hresult"] = f"{resize(hpc, Coord(82, 25)) & 0xFFFFFFFF:#010x}" + elif step == "probe": + entry.update(cursor_probe(process.dwProcessId)) + entry["bytes_after"] = sum(len(chunk) for chunk in chunks) + step_log.append(entry) + steps = () if marker_ms is None and done_marker in b"".join(chunks): marker_ms = round((time.monotonic() - started) * 1000) break if kernel.WaitForSingleObject(process.hProcess, 100) == 0: break + result["step_log"] = step_log result["marker_ms"] = marker_ms code = wt.DWORD() kernel.GetExitCodeProcess(process.hProcess, ctypes.byref(code)) @@ -237,11 +294,15 @@ def main() -> int: b"133;A", ), } + variants: list[tuple[str, ...]] = [(), ("resize",), ("probe",), ("resize", "probe", "probe")] for name, (command, marker) in interactive.items(): - case = run_case(library, command, PSEUDOCONSOLE_WIN32_INPUT_MODE, args.wait, marker) - case["shell"] = name - print(json.dumps(case, ensure_ascii=False), flush=True) - report["cases"].append(case) + for steps in variants: + case = run_case( + library, command, PSEUDOCONSOLE_WIN32_INPUT_MODE, args.wait, marker, steps, + ) + case["shell"] = name + print(json.dumps(case, ensure_ascii=False), flush=True) + report["cases"].append(case) if args.output: os.makedirs(os.path.dirname(os.path.abspath(args.output)), exist_ok=True) with open(args.output, "w", encoding="utf-8") as handle: From 9daaec29d8cc9172204864a9bfd86bd9b3a672f4 Mon Sep 17 00:00:00 2001 From: kuddev Date: Tue, 22 Sep 2026 12:17:14 +0800 Subject: [PATCH 09/20] trace: report conout reader exit and empty readable wakes during boot --- nebula_terminal/src/event_loop.rs | 10 ++++++++++ nebula_terminal/src/tty/windows/blocking.rs | 7 ++++++- scripts/conformance/conpty_smoke.py | 13 ++++++++++--- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/nebula_terminal/src/event_loop.rs b/nebula_terminal/src/event_loop.rs index 30e0397..207ff4f 100644 --- a/nebula_terminal/src/event_loop.rs +++ b/nebula_terminal/src/event_loop.rs @@ -480,6 +480,16 @@ where self.event_proxy.send_event(Event::Wakeup); } + // Boot profiling: a readable wake that carried no bytes is the + // signature of a lost or spurious wakeup; report only the first few. + if processed == 0 { + use std::sync::atomic::{AtomicUsize, Ordering}; + static EMPTY_READS: AtomicUsize = AtomicUsize::new(0); + if EMPTY_READS.fetch_add(1, Ordering::Relaxed) < 8 { + crate::pty_trace("pty_read: readable wake with no bytes"); + } + } + Ok(processed) } diff --git a/nebula_terminal/src/tty/windows/blocking.rs b/nebula_terminal/src/tty/windows/blocking.rs index 644b926..9f46cec 100644 --- a/nebula_terminal/src/tty/windows/blocking.rs +++ b/nebula_terminal/src/tty/windows/blocking.rs @@ -70,6 +70,7 @@ impl UnblockedReader { Poll::Ready(Ok(0)) => { // Either the pipe is closed or the reader is at its EOF. // In any case, we are done. + crate::pty_trace("conout reader thread: EOF"); return; }, @@ -84,10 +85,14 @@ impl UnblockedReader { }, // Windows reports normal anonymous-pipe EOF this way. - Poll::Ready(Err(e)) if e.kind() == io::ErrorKind::BrokenPipe => return, + Poll::Ready(Err(e)) if e.kind() == io::ErrorKind::BrokenPipe => { + crate::pty_trace("conout reader thread: broken pipe"); + return; + }, Poll::Ready(Err(e)) => { log::error!("error writing to pipe: {}", e); + crate::pty_trace("conout reader thread: read error"); return; }, diff --git a/scripts/conformance/conpty_smoke.py b/scripts/conformance/conpty_smoke.py index 94e679f..7a26cf3 100644 --- a/scripts/conformance/conpty_smoke.py +++ b/scripts/conformance/conpty_smoke.py @@ -225,7 +225,8 @@ def run_case( result["exit"] = code.value # The in-box host flushes trailing output while ClosePseudoConsole drains # the pipe; the reader thread keeps consuming during that call. - kernel.CloseHandle(conout_write) + if conout_write is not None: + kernel.CloseHandle(conout_write) close(hpc) thread.join(2.0) output = b"".join(chunks) @@ -238,7 +239,8 @@ def run_case( kernel.CloseHandle(process.hProcess) kernel.DeleteProcThreadAttributeList(attributes) for handle in (conin_write, conin_read, conout_read): - kernel.CloseHandle(handle) + if handle is not None: + kernel.CloseHandle(handle) return result @@ -294,7 +296,12 @@ def main() -> int: b"133;A", ), } - variants: list[tuple[str, ...]] = [(), ("resize",), ("probe",), ("resize", "probe", "probe")] + variants: list[tuple[str, ...]] = [ + (), + ("resize", "probe", "probe"), + ("early_close",), + ("early_close", "resize", "probe", "probe"), + ] for name, (command, marker) in interactive.items(): for steps in variants: case = run_case( From 2b4d07017b38599ee62553cd99d5c14134422f2f Mon Sep 17 00:00:00 2001 From: kuddev Date: Tue, 22 Sep 2026 12:35:36 +0800 Subject: [PATCH 10/20] ci: diagnose the traced ARM64 package --- .github/workflows/windows-arm64-diagnostics.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/windows-arm64-diagnostics.yml b/.github/workflows/windows-arm64-diagnostics.yml index daaf868..7767a79 100644 --- a/.github/workflows/windows-arm64-diagnostics.yml +++ b/.github/workflows/windows-arm64-diagnostics.yml @@ -20,7 +20,7 @@ permissions: env: # Dispatch is only available once the workflow exists on the default branch; # branch pushes diagnose the latest ARM64 package produced by the release run. - PACKAGE_RUN: ${{ inputs.package_run || '35618479129' }} + PACKAGE_RUN: ${{ inputs.package_run || '35686321517' }} jobs: diagnose: From 11c61f9b3a99f64622d27b2c447dadd0cdbf9b28 Mon Sep 17 00:00:00 2001 From: kuddev Date: Tue, 22 Sep 2026 12:41:48 +0800 Subject: [PATCH 11/20] trace: dump the first ConPTY handshake bytes during ARM64 conformance --- .github/workflows/release.yml | 1 + nebula_terminal/src/event_loop.rs | 30 ++++++++++++++++++++++++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9d5ebfe..5e35d3d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -596,6 +596,7 @@ jobs: shell: pwsh env: NEBULA_RESIZE_TRACE: "1" + NEBULA_BOOT_TRACE: "1" run: | python scripts/conformance/windows_standard_user.py scripts/conformance/run.py --app target/release/pebrel.exe --platform windows-aarch64 --output dist/conformance/windows-arm64-report.json exit $LASTEXITCODE diff --git a/nebula_terminal/src/event_loop.rs b/nebula_terminal/src/event_loop.rs index 207ff4f..339bd04 100644 --- a/nebula_terminal/src/event_loop.rs +++ b/nebula_terminal/src/event_loop.rs @@ -426,13 +426,23 @@ where Ok(got) => { // Startup profiling: the process-wide first PTY output ≈ // the console host finished its bring-up handshake and - // the shell started talking. + // the shell started talking. The first chunks are dumped + // escaped so a silent boot can be aligned with the host's + // handshake byte for byte. { - use std::sync::atomic::{AtomicBool, Ordering}; - static FIRST_BYTES: AtomicBool = AtomicBool::new(false); - if !FIRST_BYTES.swap(true, Ordering::Relaxed) { + use std::sync::atomic::{AtomicUsize, Ordering}; + static CHUNKS: AtomicUsize = AtomicUsize::new(0); + let index = CHUNKS.fetch_add(1, Ordering::Relaxed); + if index == 0 { crate::pty_trace("first conout bytes"); } + if index < 12 { + let shown = &buf[unprocessed..unprocessed + got.min(200)]; + crate::pty_trace(&format!( + "conout chunk {index} ({got} bytes): {}", + shown.escape_ascii() + )); + } } unprocessed += got; }, @@ -505,6 +515,18 @@ where break 'write_many; }, Ok(n) => { + { + use std::sync::atomic::{AtomicUsize, Ordering}; + static WRITES: AtomicUsize = AtomicUsize::new(0); + let index = WRITES.fetch_add(1, Ordering::Relaxed); + if index < 12 { + let shown = ¤t.remaining_bytes()[..n.min(200)]; + crate::pty_trace(&format!( + "conin write {index} ({n} bytes): {}", + shown.escape_ascii() + )); + } + } current.advance(n); if current.finished() { state.goto_next(); From aa7e91178a16732e6ddd8940249a1bff414517b6 Mon Sep 17 00:00:00 2001 From: kuddev Date: Tue, 22 Sep 2026 13:28:10 +0800 Subject: [PATCH 12/20] ci: boot explicit -e commands through the ARM64 package --- .../workflows/windows-arm64-diagnostics.yml | 22 +---- scripts/conformance/explicit_command_boot.py | 98 +++++++++++++++++++ 2 files changed, 102 insertions(+), 18 deletions(-) create mode 100644 scripts/conformance/explicit_command_boot.py diff --git a/.github/workflows/windows-arm64-diagnostics.yml b/.github/workflows/windows-arm64-diagnostics.yml index 7767a79..99dc929 100644 --- a/.github/workflows/windows-arm64-diagnostics.yml +++ b/.github/workflows/windows-arm64-diagnostics.yml @@ -12,6 +12,7 @@ on: paths: - .github/workflows/windows-arm64-diagnostics.yml - scripts/conformance/conpty_smoke.py + - scripts/conformance/explicit_command_boot.py permissions: contents: read @@ -20,7 +21,7 @@ permissions: env: # Dispatch is only available once the workflow exists on the default branch; # branch pushes diagnose the latest ARM64 package produced by the release run. - PACKAGE_RUN: ${{ inputs.package_run || '35686321517' }} + PACKAGE_RUN: ${{ inputs.package_run || '35687864774' }} jobs: diagnose: @@ -63,25 +64,10 @@ jobs: "{0}: {1} machine={2:x4}" -f $name, $command.Source, $machine } python -c "import platform, sys; print(sys.version, platform.machine())" - - name: Conformance with the side-loaded runtime + - name: Explicit -e commands through the application if: always() shell: pwsh - run: python scripts/conformance/windows_standard_user.py scripts/conformance/run.py --app diagnostic-runtime/pebrel.exe --platform windows-aarch64 --output diagnostic-output/sideload/windows-arm64-report.json --no-platform-golden - - name: Conformance with the in-box ConPTY - if: always() - shell: pwsh - run: | - Copy-Item -Recurse diagnostic-runtime diagnostic-runtime-inbox - Remove-Item diagnostic-runtime-inbox/runtime/conpty.dll, diagnostic-runtime-inbox/runtime/OpenConsole.exe - python scripts/conformance/windows_standard_user.py scripts/conformance/run.py --app diagnostic-runtime-inbox/pebrel.exe --platform windows-aarch64 --output diagnostic-output/inbox/windows-arm64-report.json --no-platform-golden - - name: ConPTY smoke with an ordinary token - if: always() - shell: pwsh - run: python scripts/conformance/windows_standard_user.py scripts/conformance/conpty_smoke.py --runtime diagnostic-runtime/runtime --wait 25 --prompt-script "$env:TEMP/pebrel_prompt.ps1" --output diagnostic-output/smoke-ordinary.json - - name: ConPTY smoke with the runner token - if: always() - shell: pwsh - run: python scripts/conformance/conpty_smoke.py --runtime diagnostic-runtime/runtime --wait 25 --prompt-script "$env:TEMP/pebrel_prompt.ps1" --output diagnostic-output/smoke-runner.json + run: python scripts/conformance/windows_standard_user.py scripts/conformance/explicit_command_boot.py diagnostic-runtime/pebrel.exe diagnostic-output/explicit-commands.json - uses: actions/upload-artifact@v4 if: always() with: diff --git a/scripts/conformance/explicit_command_boot.py b/scripts/conformance/explicit_command_boot.py new file mode 100644 index 0000000..8409167 --- /dev/null +++ b/scripts/conformance/explicit_command_boot.py @@ -0,0 +1,98 @@ +"""Boot Pebrel with explicit `-e` commands and dump what each pane renders. + +Separates "the shell hangs at startup" from "the PTY read path is dead": +cmd, PowerShell 5.1 and pwsh are launched through the same ConPTY and +environment path as the default tab, and every pane is read for 15s. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +sys.path.insert(0, os.fspath(Path(__file__).resolve().parent.parent)) +from conformance.harness import RuntimeClient # noqa: E402 + +app = Path(sys.argv[1]).resolve() +output = Path(sys.argv[2]) +commands = { + "cmd": ["cmd.exe", "/d", "/k", "echo PEBREL_SMOKE_OK"], + "powershell": ["powershell.exe", "-NoLogo", "-NoProfile", "-NoExit", "-Command", "Write-Output PEBREL_SMOKE_OK"], + "powershell-profile": ["powershell.exe", "-NoLogo", "-NoExit", "-Command", "Write-Output PEBREL_SMOKE_OK"], + "pwsh": ["pwsh.exe", "-NoLogo", "-NoProfile", "-NoExit", "-Command", "Write-Output PEBREL_SMOKE_OK"], +} +report: dict[str, object] = {} +for name, command in commands.items(): + with tempfile.TemporaryDirectory(prefix="pebrel-cmd-", ignore_cleanup_errors=True) as root: + config = Path(root) / "config" + work = Path(root) / "work" + config.mkdir() + work.mkdir() + (config / "pebrel_settings.txt").write_text( + "keep_session=0\ntray=0\nrestore_session=0\nresume_ai=0\nfetch=0\n" + "auto_check_updates=0\nai_hooks=0\nwindowing_behavior=use_new\n", + encoding="utf-8", + ) + env = dict(os.environ, PEBREL_CONFIG_DIR=os.fspath(config), NEBULA_CONFIG_DIR=os.fspath(config), NEBULA_BOOT_TRACE="1") + env.pop("PEBREL_RUNTIME_ENDPOINT", None) + log = open(Path(root) / "app.log", "wb") + process = subprocess.Popen( + [os.fspath(app), "--working-directory", os.fspath(work), "-e", *command], + cwd=work, env=env, stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT, + ) + entry: dict[str, object] = {"command": command} + port = config / "runtime.port" + deadline = time.monotonic() + 20 + client = None + while time.monotonic() < deadline and client is None: + if port.is_file(): + try: + client = RuntimeClient.from_port_file(port, timeout=5.0) + client.request("runtime.describe", timeout=1.0) + except Exception: + client = None + time.sleep(0.1) + if client is None: + entry["error"] = "runtime did not come up" + else: + text = "" + started = time.monotonic() + first_text_ms = None + while time.monotonic() - started < 15: + try: + snapshot = client.request("runtime.snapshot", timeout=5.0)["result"] + window = snapshot["windows"][0] + pane = window["tabs"][0]["panes"][0] + read = client.request( + "pane.read", {"window_id": window["id"], "pane_id": pane["id"], "lines": 40}, timeout=5.0 + )["result"] + text = read.get("text", "") + entry["exited"] = read.get("exited") + entry["cwd"] = pane.get("cwd") + if text.strip() and first_text_ms is None: + first_text_ms = round((time.monotonic() - started) * 1000) + if "PEBREL_SMOKE_OK" in text: + break + except Exception as error: + entry["read_error"] = str(error) + time.sleep(0.25) + entry["first_text_ms"] = first_text_ms + entry["marker"] = "PEBREL_SMOKE_OK" in text + entry["text"] = text.strip()[:600] + # The shell keeps the work directory as its cwd; take the whole tree down. + subprocess.run(["taskkill", "/T", "/F", "/PID", str(process.pid)], capture_output=True) + process.wait(10) + log.close() + entry["app_log"] = [ + line for line in (Path(root) / "app.log").read_text(encoding="utf-8", errors="replace").splitlines() + if "prepaint" not in line and "resize-trace" not in line + ][:40] + report[name] = entry + print(name, json.dumps({k: v for k, v in entry.items() if k != "app_log"}, ensure_ascii=False)[:400], flush=True) +output.parent.mkdir(parents=True, exist_ok=True) +output.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") From 39cde202b016ce997012fe512a95733ff5bab65c Mon Sep 17 00:00:00 2001 From: kuddev Date: Tue, 22 Sep 2026 13:56:20 +0800 Subject: [PATCH 13/20] ci: replay PowerShell 5.1 with the application environment on ARM64 --- scripts/conformance/conpty_smoke.py | 16 ++- scripts/conformance/explicit_command_boot.py | 124 +++++++++++++++++-- 2 files changed, 128 insertions(+), 12 deletions(-) diff --git a/scripts/conformance/conpty_smoke.py b/scripts/conformance/conpty_smoke.py index 7a26cf3..d781cf8 100644 --- a/scripts/conformance/conpty_smoke.py +++ b/scripts/conformance/conpty_smoke.py @@ -19,6 +19,7 @@ import time PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE = 0x00020016 EXTENDED_STARTUPINFO_PRESENT = 0x00080000 +CREATE_UNICODE_ENVIRONMENT = 0x00000400 STARTF_USESTDHANDLES = 0x00000100 PSEUDOCONSOLE_WIN32_INPUT_MODE = 0x4 @@ -124,9 +125,14 @@ def run_case( wait_seconds: float, done_marker: bytes = b"CONPTY_SMOKE_OK", steps: tuple[str, ...] = (), + env: dict[str, str] | None = None, + cwd: str | None = None, ) -> dict[str, object]: """Interactive shells never exit; the marker ends the wait once it renders. + `env` replaces the inherited block (CREATE_UNICODE_ENVIRONMENT) and `cwd` + the inherited directory, so Pebrel's own child environment can be replayed. + `steps` replays Pebrel's post-spawn sequence 30ms after the first output: `resize` calls ResizePseudoConsole, `probe` attaches to the client console and reads its cursor, mirroring the align-sync and align stages. @@ -174,8 +180,14 @@ def run_case( startup.lpAttributeList = ctypes.cast(attributes, wt.LPVOID) process = ProcessInfo() cmdline = ctypes.create_unicode_buffer(command) - if not kernel.CreateProcessW(None, cmdline, None, None, False, EXTENDED_STARTUPINFO_PRESENT, - None, None, ctypes.byref(startup), ctypes.byref(process)): + creation_flags = EXTENDED_STARTUPINFO_PRESENT + env_block = None + if env is not None: + creation_flags |= CREATE_UNICODE_ENVIRONMENT + block = "".join(f"{name}={value}\0" for name, value in sorted(env.items(), key=lambda item: item[0].upper())) + env_block = ctypes.create_unicode_buffer(block + "\0") + if not kernel.CreateProcessW(None, cmdline, None, None, False, creation_flags, + env_block, cwd, ctypes.byref(startup), ctypes.byref(process)): result["error"] = f"CreateProcessW failed: {ctypes.get_last_error()}" return result result["pid"] = process.dwProcessId diff --git a/scripts/conformance/explicit_command_boot.py b/scripts/conformance/explicit_command_boot.py index 8409167..21b7777 100644 --- a/scripts/conformance/explicit_command_boot.py +++ b/scripts/conformance/explicit_command_boot.py @@ -3,6 +3,11 @@ Separates "the shell hangs at startup" from "the PTY read path is dead": cmd, PowerShell 5.1 and pwsh are launched through the same ConPTY and environment path as the default tab, and every pane is read for 15s. + +The `cmd-env` case captures the exact environment block Pebrel hands its +children; PowerShell 5.1 is then replayed outside the application with that +block and/or that working directory, and the differing variables are bisected +when the block alone reproduces a silent start. """ from __future__ import annotations @@ -16,18 +21,24 @@ import time from pathlib import Path sys.path.insert(0, os.fspath(Path(__file__).resolve().parent.parent)) +sys.path.insert(0, os.fspath(Path(__file__).resolve().parent)) +import conpty_smoke # noqa: E402 from conformance.harness import RuntimeClient # noqa: E402 +MARKER = "PEBREL_SMOKE_OK" +POWERSHELL_51 = ["powershell.exe", "-NoLogo", "-NoProfile", "-NoExit", "-Command", f"Write-Output {MARKER}"] +REPLAY_WAIT = 10.0 + app = Path(sys.argv[1]).resolve() output = Path(sys.argv[2]) -commands = { - "cmd": ["cmd.exe", "/d", "/k", "echo PEBREL_SMOKE_OK"], - "powershell": ["powershell.exe", "-NoLogo", "-NoProfile", "-NoExit", "-Command", "Write-Output PEBREL_SMOKE_OK"], - "powershell-profile": ["powershell.exe", "-NoLogo", "-NoExit", "-Command", "Write-Output PEBREL_SMOKE_OK"], - "pwsh": ["pwsh.exe", "-NoLogo", "-NoProfile", "-NoExit", "-Command", "Write-Output PEBREL_SMOKE_OK"], -} +runtime_library = app.parent / "runtime" / "conpty.dll" report: dict[str, object] = {} -for name, command in commands.items(): +app_env: dict[str, str] | None = None +app_cwd: str | None = None + + +def boot(name: str, command: list[str], env_dump: Path | None = None) -> dict[str, object]: + global app_env with tempfile.TemporaryDirectory(prefix="pebrel-cmd-", ignore_cleanup_errors=True) as root: config = Path(root) / "config" work = Path(root) / "work" @@ -38,6 +49,9 @@ for name, command in commands.items(): "auto_check_updates=0\nai_hooks=0\nwindowing_behavior=use_new\n", encoding="utf-8", ) + if env_dump is not None: + env_dump = Path(root) / env_dump + command = [*command, f"set > {env_dump}"] env = dict(os.environ, PEBREL_CONFIG_DIR=os.fspath(config), NEBULA_CONFIG_DIR=os.fspath(config), NEBULA_BOOT_TRACE="1") env.pop("PEBREL_RUNTIME_ENDPOINT", None) log = open(Path(root) / "app.log", "wb") @@ -76,14 +90,27 @@ for name, command in commands.items(): entry["cwd"] = pane.get("cwd") if text.strip() and first_text_ms is None: first_text_ms = round((time.monotonic() - started) * 1000) - if "PEBREL_SMOKE_OK" in text: + if MARKER in text: + break + if env_dump is not None and env_dump.is_file() and env_dump.stat().st_size > 0: + time.sleep(1.0) break except Exception as error: entry["read_error"] = str(error) time.sleep(0.25) entry["first_text_ms"] = first_text_ms - entry["marker"] = "PEBREL_SMOKE_OK" in text + entry["marker"] = MARKER in text entry["text"] = text.strip()[:600] + if env_dump is not None and env_dump.is_file(): + parsed: dict[str, str] = {} + for line in env_dump.read_text(encoding="mbcs", errors="replace").splitlines(): + name, separator, value = line.partition("=") + if separator and name and not name.startswith("="): + parsed[name] = value + if parsed: + # Windows names are case-insensitive; os.environ upper-cases them. + app_env = {name.upper(): value for name, value in parsed.items()} + entry["app_env_count"] = len(parsed) # The shell keeps the work directory as its cwd; take the whole tree down. subprocess.run(["taskkill", "/T", "/F", "/PID", str(process.pid)], capture_output=True) process.wait(10) @@ -92,7 +119,84 @@ for name, command in commands.items(): line for line in (Path(root) / "app.log").read_text(encoding="utf-8", errors="replace").splitlines() if "prepaint" not in line and "resize-trace" not in line ][:40] - report[name] = entry print(name, json.dumps({k: v for k, v in entry.items() if k != "app_log"}, ensure_ascii=False)[:400], flush=True) + return entry + + +def replay(name: str, env: dict[str, str] | None, cwd: str | None) -> bool: + """Run PowerShell 5.1 through the smoke path; True when the marker rendered.""" + library = os.fspath(runtime_library) if runtime_library.is_file() else None + command = subprocess.list2cmdline(POWERSHELL_51) + result = conpty_smoke.run_case(library, command, 0, REPLAY_WAIT, done_marker=MARKER.encode(), env=env, cwd=cwd) + passed = result.get("marker_ms") is not None + summary = {k: result.get(k) for k in ("error", "first_output_ms", "marker_ms", "output_bytes")} + print("replay", name, "PASS" if passed else "SILENT", json.dumps(summary), flush=True) + report.setdefault("replays", {})[name] = summary | {"passed": passed} # type: ignore[index] + return passed + + +def bisect(base: dict[str, str], target: dict[str, str]) -> None: + """Find which of the variables that differ between base and target break PS 5.1.""" + differing = sorted({*base, *target}, key=str.upper) + differing = [name for name in differing if base.get(name) != target.get(name)] + report["env_diff"] = {name: {"runner": base.get(name), "app": target.get(name)} for name in differing} + print(f"bisect over {len(differing)} differing variables", flush=True) + + def with_changes(names: list[str]) -> dict[str, str]: + env = dict(base) + for name in names: + if name in target: + env[name] = target[name] + else: + env.pop(name, None) + return env + + if replay("runner-env-plus-all-diffs", with_changes(differing), None): + report["bisect"] = "applying every differing variable to the runner environment still passes" + return + suspects = list(differing) + rounds = 0 + while len(suspects) > 1 and rounds < 12: + rounds += 1 + half = suspects[: len(suspects) // 2] + if not replay(f"round{rounds}-first-half({len(half)})", with_changes(half), None): + suspects = half + continue + rest = suspects[len(suspects) // 2 :] + if not replay(f"round{rounds}-second-half({len(rest)})", with_changes(rest), None): + suspects = rest + continue + report["bisect"] = f"no single half reproduces; remaining suspects: {suspects}" + return + report["bisect"] = {"culprits": suspects, "values": {name: target.get(name) for name in suspects}} + print("bisect result:", report["bisect"], flush=True) + + +def redacted(env: dict[str, str] | None) -> dict[str, str] | None: + if env is None: + return None + secret = ("TOKEN", "SECRET", "PASSWORD", "_KEY") + return {name: ("" if any(part in name.upper() for part in secret) else value) for name, value in env.items()} + + +# Same shape as the panes' working directory (under TEMP), but it outlives boot(). +app_cwd = os.fspath(Path(tempfile.mkdtemp(prefix="pebrel-cmd-")) / "work") +os.mkdir(app_cwd) +report["cmd-env"] = boot("cmd-env", ["cmd.exe", "/d", "/k"], env_dump=Path("app-env.txt")) +report["powershell"] = boot("powershell", POWERSHELL_51) +report["runner_env"] = redacted(dict(os.environ)) +report["app_env"] = redacted(app_env) +if app_env is not None: + control = replay("inherited-env-inherited-cwd", None, None) + env_only = replay("app-env-inherited-cwd", app_env, None) + cwd_only = replay("inherited-env-app-cwd", None, app_cwd) + replay("app-env-app-cwd", app_env, app_cwd) + if control and not env_only: + bisect(dict(os.environ), app_env) + if isinstance(report.get("env_diff"), dict): + report["env_diff"] = { + name: {side: redacted({name: value})[name] if value is not None else None for side, value in sides.items()} # type: ignore[index] + for name, sides in report["env_diff"].items() # type: ignore[union-attr] + } output.parent.mkdir(parents=True, exist_ok=True) output.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") From 415073bdd12ea9f88197feeec748f2089b8e4ba6 Mon Sep 17 00:00:00 2001 From: kuddev Date: Tue, 22 Sep 2026 14:04:32 +0800 Subject: [PATCH 14/20] ci: probe which PSModulePath entry stalls PowerShell 5.1 on ARM64 --- scripts/conformance/explicit_command_boot.py | 42 ++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/scripts/conformance/explicit_command_boot.py b/scripts/conformance/explicit_command_boot.py index 21b7777..149d9b4 100644 --- a/scripts/conformance/explicit_command_boot.py +++ b/scripts/conformance/explicit_command_boot.py @@ -193,6 +193,48 @@ if app_env is not None: replay("app-env-app-cwd", app_env, app_cwd) if control and not env_only: bisect(dict(os.environ), app_env) + if isinstance(report.get("bisect"), dict) and report["bisect"]["culprits"] == ["PSMODULEPATH"]: # type: ignore[index] + # Which entry of the registry-only module path stalls PowerShell 5.1, + # and is the stall tied to the interactive host (PSReadLine import)? + runner_entries = [entry for entry in os.environ.get("PSMODULEPATH", "").split(";") if entry] + app_entries = [entry for entry in app_env.get("PSMODULEPATH", "").split(";") if entry] + windows_default = [ + entry for entry in app_entries + if entry.lower().startswith((r"c:\program files\windowspowershell\modules", r"c:\windows\system32")) + ] + pwsh_entries = [entry for entry in runner_entries if entry not in app_entries] + variants: dict[str, tuple[list[str], list[str]]] = { + "app-entries": (app_entries, POWERSHELL_51), + "pwsh-dirs-then-app-entries": (pwsh_entries + app_entries, POWERSHELL_51), + "windows-default-only": (windows_default, POWERSHELL_51), + "windows-default-non-interactive": ( + windows_default, + ["powershell.exe", "-NoLogo", "-NoProfile", "-Command", f"Write-Output {MARKER}"], + ), + "windows-default-import-psreadline-non-interactive": ( + windows_default, + ["powershell.exe", "-NoLogo", "-NoProfile", "-Command", f"Import-Module PSReadLine; Write-Output {MARKER}"], + ), + "windows-default-list-psreadline-non-interactive": ( + windows_default, + ["powershell.exe", "-NoLogo", "-NoProfile", "-Command", + f"Get-Module -ListAvailable PSReadLine | ForEach-Object {{ $_.Version.ToString() + ' ' + $_.Path }}; Write-Output {MARKER}"], + ), + } + for index, entry in enumerate(app_entries): + variants[f"app-entries-without-{index}"] = ([e for e in app_entries if e != entry], POWERSHELL_51) + for name, (entries, command) in variants.items(): + env = dict(os.environ) + env["PSMODULEPATH"] = ";".join(entries) + library = os.fspath(runtime_library) if runtime_library.is_file() else None + result = conpty_smoke.run_case( + library, subprocess.list2cmdline(command), 0, REPLAY_WAIT, done_marker=MARKER.encode(), env=env, cwd=None + ) + summary = {k: result.get(k) for k in ("error", "first_output_ms", "marker_ms", "output_bytes")} + summary["output_head"] = str(result.get("output_head", ""))[:300] + summary["entries"] = entries + print("psmodulepath", name, "PASS" if result.get("marker_ms") is not None else "SILENT", json.dumps(summary)[:400], flush=True) + report.setdefault("psmodulepath_variants", {})[name] = summary # type: ignore[index] if isinstance(report.get("env_diff"), dict): report["env_diff"] = { name: {side: redacted({name: value})[name] if value is not None else None for side, value in sides.items()} # type: ignore[index] From f65ea59acf2261f9ebeb474dfeabea184236de26 Mon Sep 17 00:00:00 2001 From: kuddev Date: Tue, 22 Sep 2026 14:26:16 +0800 Subject: [PATCH 15/20] ci: keep Windows PowerShell alive on the ARM64 runner image --- .github/workflows/release.yml | 3 +++ .../workflows/windows-arm64-diagnostics.yml | 13 +++++++++++ scripts/conformance/conpty_smoke.py | 2 +- scripts/conformance/explicit_command_boot.py | 7 +++++- .../conformance/windows_arm64_module_path.ps1 | 23 +++++++++++++++++++ 5 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 scripts/conformance/windows_arm64_module_path.ps1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5e35d3d..25ee9e3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -592,6 +592,9 @@ jobs: - name: Package native ARM64 ZIP shell: pwsh run: ./scripts/package-release.ps1 -Version $env:PACKAGE_VERSION -Architecture arm64 -OutputDirectory dist -TargetDirectory target + - name: Keep Windows PowerShell alive on the ARM64 runner image + shell: pwsh + run: ./scripts/conformance/windows_arm64_module_path.ps1 - name: Run ARM64 Windows conformance shell: pwsh env: diff --git a/.github/workflows/windows-arm64-diagnostics.yml b/.github/workflows/windows-arm64-diagnostics.yml index 99dc929..76ccecb 100644 --- a/.github/workflows/windows-arm64-diagnostics.yml +++ b/.github/workflows/windows-arm64-diagnostics.yml @@ -13,6 +13,7 @@ on: - .github/workflows/windows-arm64-diagnostics.yml - scripts/conformance/conpty_smoke.py - scripts/conformance/explicit_command_boot.py + - scripts/conformance/windows_arm64_module_path.ps1 permissions: contents: read @@ -68,6 +69,18 @@ jobs: if: always() shell: pwsh run: python scripts/conformance/windows_standard_user.py scripts/conformance/explicit_command_boot.py diagnostic-runtime/pebrel.exe diagnostic-output/explicit-commands.json + - name: Apply the ARM64 image module-path workaround + if: always() + shell: pwsh + run: ./scripts/conformance/windows_arm64_module_path.ps1 + - name: Explicit -e commands after the workaround + if: always() + shell: pwsh + run: python scripts/conformance/windows_standard_user.py scripts/conformance/explicit_command_boot.py diagnostic-runtime/pebrel.exe diagnostic-output/explicit-commands-fixed.json + - name: Conformance after the workaround + if: always() + shell: pwsh + run: python scripts/conformance/windows_standard_user.py scripts/conformance/run.py --app diagnostic-runtime/pebrel.exe --platform windows-aarch64 --output diagnostic-output/fixed/windows-arm64-report.json - uses: actions/upload-artifact@v4 if: always() with: diff --git a/scripts/conformance/conpty_smoke.py b/scripts/conformance/conpty_smoke.py index d781cf8..6dc0b98 100644 --- a/scripts/conformance/conpty_smoke.py +++ b/scripts/conformance/conpty_smoke.py @@ -246,7 +246,7 @@ def run_case( first_output_ms = round((time.monotonic() - started) * 1000) result["first_output_ms"] = first_output_ms result["output_bytes"] = len(output) - result["output_head"] = output[:400].decode("utf-8", "replace") + result["output_head"] = output[:2000].decode("utf-8", "replace") kernel.CloseHandle(process.hThread) kernel.CloseHandle(process.hProcess) kernel.DeleteProcThreadAttributeList(attributes) diff --git a/scripts/conformance/explicit_command_boot.py b/scripts/conformance/explicit_command_boot.py index 149d9b4..1b19277 100644 --- a/scripts/conformance/explicit_command_boot.py +++ b/scripts/conformance/explicit_command_boot.py @@ -221,6 +221,11 @@ if app_env is not None: f"Get-Module -ListAvailable PSReadLine | ForEach-Object {{ $_.Version.ToString() + ' ' + $_.Path }}; Write-Output {MARKER}"], ), } + variants["windows-default-import-utility-verbose"] = ( + windows_default, + ["powershell.exe", "-NoLogo", "-NoProfile", "-Command", + f"Import-Module Microsoft.PowerShell.Utility -Verbose; Write-Output {MARKER}"], + ) for index, entry in enumerate(app_entries): variants[f"app-entries-without-{index}"] = ([e for e in app_entries if e != entry], POWERSHELL_51) for name, (entries, command) in variants.items(): @@ -231,7 +236,7 @@ if app_env is not None: library, subprocess.list2cmdline(command), 0, REPLAY_WAIT, done_marker=MARKER.encode(), env=env, cwd=None ) summary = {k: result.get(k) for k in ("error", "first_output_ms", "marker_ms", "output_bytes")} - summary["output_head"] = str(result.get("output_head", ""))[:300] + summary["output_head"] = str(result.get("output_head", ""))[:1500] summary["entries"] = entries print("psmodulepath", name, "PASS" if result.get("marker_ms") is not None else "SILENT", json.dumps(summary)[:400], flush=True) report.setdefault("psmodulepath_variants", {})[name] = summary # type: ignore[index] diff --git a/scripts/conformance/windows_arm64_module_path.ps1 b/scripts/conformance/windows_arm64_module_path.ps1 new file mode 100644 index 0000000..ec205e8 --- /dev/null +++ b/scripts/conformance/windows_arm64_module_path.ps1 @@ -0,0 +1,23 @@ +# Work around the windows-11-arm runner image: Windows PowerShell 5.1 there +# renders nothing (not even `Write-Output`) when its PSModulePath is the +# registry value alone, and recovers as soon as the PowerShell 7 module +# directories that the runner's pwsh step shell injects come first. Pebrel +# rebuilds child environments from the registry (the stock console host rule), so +# the injected entries never reach the default tab; putting them into the +# user-level registry value keeps the rebuild faithful and PS 5.1 alive. +# Evidence: .github/workflows/windows-arm64-diagnostics.yml bisect, 2026-09-22. +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +$pwsh7 = @( + (Join-Path $HOME 'Documents\PowerShell\Modules'), + (Join-Path $env:ProgramFiles 'PowerShell\Modules'), + (Join-Path $env:ProgramFiles 'PowerShell\7\Modules') +) +$machine = [Environment]::GetEnvironmentVariable('PSModulePath', 'Machine') +$user = [Environment]::GetEnvironmentVariable('PSModulePath', 'User') +$entries = @($pwsh7) + @(($user, $machine) -join ';' -split ';' | Where-Object { $_ }) +$value = ($entries | Select-Object -Unique) -join ';' +[Environment]::SetEnvironmentVariable('PSModulePath', $value, 'User') +"User PSModulePath: $value" From 41c5477c57cb2989d1f47509bfc6e70937a95fcc Mon Sep 17 00:00:00 2001 From: kuddev Date: Tue, 22 Sep 2026 21:09:45 +0800 Subject: [PATCH 16/20] fix(gpui): stop unconfirmed agent restores from tripping the session breaker snapshot_session() pinned boot_attempts at the launch value whenever any pane still had an unconfirmed AI restore target, so the 1 Hz autosave never reset the crash-loop counter. A Codex conversation waiting at its startup prompts (hook review, directory choice, "open in another app") therefore counted every normal exit as a failed start, and the third launch moved the whole workspace to session.crashed.json. The breaker only answers "did this launch reach its first autosave"; the restore target is still persisted through session_agent() and resumed on the next launch. Drop the coupling and the recovery_boot_attempts field, and add a GPUI regression that restores an unconfirmed Codex target on the third attempt and asserts the snapshot reports zero attempts. --- .../workflows/windows-arm64-diagnostics.yml | 90 ----- nebula_app/src/gpui_shell/workspace.rs | 2 - .../gpui_shell/workspace/session_recovery.rs | 17 +- nebula_app/src/gpui_shell/workspace/tests.rs | 88 +++++ scripts/conformance/conpty_smoke.py | 333 ------------------ scripts/conformance/explicit_command_boot.py | 249 ------------- 6 files changed, 96 insertions(+), 683 deletions(-) delete mode 100644 .github/workflows/windows-arm64-diagnostics.yml delete mode 100644 scripts/conformance/conpty_smoke.py delete mode 100644 scripts/conformance/explicit_command_boot.py diff --git a/.github/workflows/windows-arm64-diagnostics.yml b/.github/workflows/windows-arm64-diagnostics.yml deleted file mode 100644 index 76ccecb..0000000 --- a/.github/workflows/windows-arm64-diagnostics.yml +++ /dev/null @@ -1,90 +0,0 @@ -name: Windows ARM64 conformance diagnostics - -on: - workflow_dispatch: - inputs: - package_run: - description: Release run whose stable-package-windows-arm64 artifact should be diagnosed - required: true - type: string - push: - branches: [release/1.9.0-final] - paths: - - .github/workflows/windows-arm64-diagnostics.yml - - scripts/conformance/conpty_smoke.py - - scripts/conformance/explicit_command_boot.py - - scripts/conformance/windows_arm64_module_path.ps1 - -permissions: - contents: read - actions: read - -env: - # Dispatch is only available once the workflow exists on the default branch; - # branch pushes diagnose the latest ARM64 package produced by the release run. - PACKAGE_RUN: ${{ inputs.package_run || '35687864774' }} - -jobs: - diagnose: - runs-on: windows-11-arm - timeout-minutes: 20 - env: - PYTHONUTF8: "1" - NEBULA_RESIZE_TRACE: "1" - NEBULA_BOOT_TRACE: "1" - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - uses: actions/download-artifact@v4 - with: - name: stable-package-windows-arm64 - run-id: ${{ env.PACKAGE_RUN }} - github-token: ${{ github.token }} - path: diagnostic-package - - name: Extract the portable package - shell: pwsh - run: | - $zip = Get-ChildItem diagnostic-package -Filter *.zip | Select-Object -First 1 - Expand-Archive -LiteralPath $zip.FullName -DestinationPath diagnostic-runtime -Force - Get-ChildItem -Recurse diagnostic-runtime | Select-Object FullName, Length - - name: Describe the host - shell: pwsh - run: | - "PROCESSOR_ARCHITECTURE=$env:PROCESSOR_ARCHITECTURE" - [System.Runtime.InteropServices.RuntimeInformation]::OSDescription - [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture - whoami /groups | Select-String -Pattern 'Mandatory|Administrators' - foreach ($name in 'pwsh', 'powershell', 'cmd', 'python') { - $command = Get-Command $name -ErrorAction SilentlyContinue - if ($null -eq $command) { "${name}: not found"; continue } - $bytes = [IO.File]::ReadAllBytes($command.Source) - $offset = [BitConverter]::ToInt32($bytes, 0x3C) - $machine = [BitConverter]::ToUInt16($bytes, $offset + 4) - "{0}: {1} machine={2:x4}" -f $name, $command.Source, $machine - } - python -c "import platform, sys; print(sys.version, platform.machine())" - - name: Explicit -e commands through the application - if: always() - shell: pwsh - run: python scripts/conformance/windows_standard_user.py scripts/conformance/explicit_command_boot.py diagnostic-runtime/pebrel.exe diagnostic-output/explicit-commands.json - - name: Apply the ARM64 image module-path workaround - if: always() - shell: pwsh - run: ./scripts/conformance/windows_arm64_module_path.ps1 - - name: Explicit -e commands after the workaround - if: always() - shell: pwsh - run: python scripts/conformance/windows_standard_user.py scripts/conformance/explicit_command_boot.py diagnostic-runtime/pebrel.exe diagnostic-output/explicit-commands-fixed.json - - name: Conformance after the workaround - if: always() - shell: pwsh - run: python scripts/conformance/windows_standard_user.py scripts/conformance/run.py --app diagnostic-runtime/pebrel.exe --platform windows-aarch64 --output diagnostic-output/fixed/windows-arm64-report.json - - uses: actions/upload-artifact@v4 - if: always() - with: - name: windows-arm64-conformance-diagnostics - path: diagnostic-output - if-no-files-found: warn - retention-days: 7 diff --git a/nebula_app/src/gpui_shell/workspace.rs b/nebula_app/src/gpui_shell/workspace.rs index 571289d..818accb 100644 --- a/nebula_app/src/gpui_shell/workspace.rs +++ b/nebula_app/src/gpui_shell/workspace.rs @@ -841,7 +841,6 @@ pub struct NebulaWorkspace { /// 系统关闭按钮可能连续送来多次 should-close;确认框在场时只保留一份。 window_close_confirm_open: bool, window_close_pending: bool, - recovery_boot_attempts: u32, /// `keep_session` 关窗后 HWND 已隐藏、PTY 仍在;托盘 / mux ATTACH 用来捞回。 window_hidden: bool, /// 开窗时记下,mux `tab.new` 需要从 pump 拿到 `&mut Window`。 @@ -1073,7 +1072,6 @@ impl NebulaWorkspace { spinner_visible: std::cell::Cell::new(false), window_close_confirm_open: false, window_close_pending: false, - recovery_boot_attempts: 0, window_hidden: false, window_handle: window.window_handle(), runtime_window_id, diff --git a/nebula_app/src/gpui_shell/workspace/session_recovery.rs b/nebula_app/src/gpui_shell/workspace/session_recovery.rs index 5edfc07..a529c7c 100644 --- a/nebula_app/src/gpui_shell/workspace/session_recovery.rs +++ b/nebula_app/src/gpui_shell/workspace/session_recovery.rs @@ -10,7 +10,6 @@ impl NebulaWorkspace { window: &mut Window, cx: &mut Context, ) -> bool { - self.recovery_boot_attempts = session.boot_attempts.saturating_add(1); let mut restored = false; for tab in &session.tabs { restored |= self.restore_tab(tab, resume_ai, window, cx); @@ -50,7 +49,6 @@ impl NebulaWorkspace { } let crashed = crate::session::was_crash(&session); crate::session::mark_boot_attempt(&mut session); - self.recovery_boot_attempts = session.boot_attempts; let mut restored = 0usize; for tab in &session.tabs { if self.restore_tab(tab, resume_ai, window, cx) { @@ -154,18 +152,23 @@ impl NebulaWorkspace { /// 当前工作区 → 共享 v4 快照。设置/文档/图片 tab 不进会话(旧壳同 /// 合同);AI 会话身份优先取 hook 直报的精确 id,退而取可解析的前台 /// 程序名(claude 无 id 恢复成 `--continue`,安全判定在 schema 层)。 + /// + /// 快照一律带 `boot_attempts = 0`:断路器只回答「这次启动活到了第一次 + /// 自动保存没有」(session.rs 模块合同)。agent 恢复目标尚未被 hook / + /// 探针确认是另一回事——codex 停在 hooks 信任、目录选择或「已在别处 + /// 打开」的提示上可以是几小时,正常退出照样算一次失败的话,三次就把 + /// 整个工作区(含所有普通 tab)隔离掉了。未确认的目标本身仍随 + /// `session_agent()` 落盘,下次启动照旧接续。 pub(crate) fn snapshot_session(&self, cx: &App) -> crate::session::Session { use crate::session::{AgentSession, LaunchSession, Session, TabSession}; let mut tabs = Vec::new(); - let mut recovery_pending = false; let mut active_out = 0usize; for (ix, tab) in self.tabs.iter().enumerate() { let WorkspaceTab::Terminal { panes, tree, focused, .. } = tab else { continue }; if ix == self.active { active_out = tabs.len(); } - recovery_pending |= panes.iter().any(|pane| pane.view.read(cx).recovery_pending()); let leaf_data = |id: u64| -> (String, Option, Option) { let Some(pane) = panes.iter().find(|pane| pane.id == id) else { return (String::new(), None, None); @@ -202,10 +205,6 @@ impl NebulaWorkspace { active_pane, }); } - let mut session = Session::new(active_out, tabs); - if recovery_pending { - session.boot_attempts = self.recovery_boot_attempts; - } - session + Session::new(active_out, tabs) } } diff --git a/nebula_app/src/gpui_shell/workspace/tests.rs b/nebula_app/src/gpui_shell/workspace/tests.rs index b6103f5..a9da3d8 100644 --- a/nebula_app/src/gpui_shell/workspace/tests.rs +++ b/nebula_app/src/gpui_shell/workspace/tests.rs @@ -805,3 +805,91 @@ fn sidebar_resize_hot_zone_follows_the_theme_boundary() { // 热区宽度足以盖住线:线心两侧各 3px。 assert!(SIDEBAR_RESIZE_HANDLE_WIDTH * 0.5 > nord.divider); } + +/// 回归:冷恢复的 codex 目标停在 hooks 信任 / 目录选择 / 「已在别处打开」 +/// 提示上时,hook 报不了、探针拿不到 rollout,pane 一直是「未确认」。此前 +/// 快照把 `boot_attempts` 钉在启动时的 +1 值,1 Hz 自动保存永不归零—— +/// 三次正常退出后整个工作区被挪去 `session.crashed.json`。断路器只回答 +/// 「这次启动活到了第一次自动保存没有」,未确认的目标只随快照落盘、不计数。 +#[cfg(feature = "gpui-test-support")] +mod boot_breaker_tests { + use super::*; + use crate::session::{AgentSession, LaunchSession, LayoutSession, TabSession}; + use gpui::TestAppContext; + + #[gpui::test] + fn unconfirmed_agent_target_does_not_pin_the_boot_breaker(cx: &mut TestAppContext) { + cx.update(|cx| { + gpui_component::init(cx); + crate::gpui_shell::math_view::register(cx); + crate::gpui_shell::file_editor::init(cx); + super::super::init(cx); + cx.set_reduce_motion(true); + windowing::initialize(cx, crate::runtime_api::RuntimeHub::new()); + }); + let mut workspace = None; + let (_, mut window) = cx.add_window_view(|window, cx| { + let view = cx.new(|cx| { + NebulaWorkspace::new( + window, + None, + None, + 1, + crate::runtime_api::RuntimeHub::new(), + windowing::WorkspaceStartup::Empty, + windowing::WindowRole::Regular, + cx, + ) + }); + workspace = Some(view.clone()); + Root::new(view, window, cx) + }); + let workspace = workspace.unwrap(); + + let saved = AgentSession { + source: "codex".into(), + session_id: Some("saved-42".into()), + session_file: None, + }; + let launch = LaunchSession::Shell { + name: "missing".into(), + program: "pebrel-test-missing-shell-executable".into(), + args: vec![], + }; + let tab = TabSession { + cwd: String::new(), + custom_name: None, + color: None, + launch: Some(launch.clone()), + layout: Some(LayoutSession::Pane { + cwd: String::new(), + agent: Some(saved.clone()), + launch: Some(launch), + }), + active_pane: 0, + }; + // 第三次启动:磁盘上的计数已被 `mark_boot_attempt` 抬到 2。 + let mut session = crate::session::Session::new(0, vec![tab]); + session.boot_attempts = 2; + window.update(|window, cx| { + workspace.update(cx, |workspace, cx| { + assert!(workspace.restore_update_session(&session, true, window, cx)); + let pane = workspace.tabs.last().and_then(|tab| tab.focused_view()).unwrap(); + assert!(pane.read(cx).recovery_pending(), "codex 目标尚未被 hook / 探针确认"); + + let snapshot = workspace.snapshot_session(cx); + assert_eq!(snapshot.boot_attempts, 0, "未确认的恢复目标不得钉住断路器计数"); + assert!( + crate::session::should_restore(&snapshot), + "首次自动保存即让下次启动照常恢复" + ); + let Some(LayoutSession::Pane { agent, .. }) = + snapshot.tabs.last().and_then(|tab| tab.layout.as_ref()) + else { + panic!("restored tab must snapshot as a single pane"); + }; + assert_eq!(agent.as_ref(), Some(&saved), "恢复目标本身仍随快照落盘,下次照旧接续"); + }) + }); + } +} diff --git a/scripts/conformance/conpty_smoke.py b/scripts/conformance/conpty_smoke.py deleted file mode 100644 index 6dc0b98..0000000 --- a/scripts/conformance/conpty_smoke.py +++ /dev/null @@ -1,333 +0,0 @@ -"""Spawn a shell through ConPTY without Pebrel and report its first output. - -Separates runtime failures (conpty.dll, OpenConsole.exe, the shell itself) -from application failures when a platform renders no initial shell output. -Both the side-loaded pair and the in-box kernel32 ConPTY are exercised. -""" - -from __future__ import annotations - -import argparse -import ctypes -from ctypes import wintypes as wt -import json -import os -import shutil -import sys -import threading -import time - -PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE = 0x00020016 -EXTENDED_STARTUPINFO_PRESENT = 0x00080000 -CREATE_UNICODE_ENVIRONMENT = 0x00000400 -STARTF_USESTDHANDLES = 0x00000100 -PSEUDOCONSOLE_WIN32_INPUT_MODE = 0x4 - - -class Coord(ctypes.Structure): - _fields_ = [("X", ctypes.c_short), ("Y", ctypes.c_short)] - - -class StartupInfo(ctypes.Structure): - _fields_ = [ - ("cb", wt.DWORD), ("lpReserved", wt.LPWSTR), ("lpDesktop", wt.LPWSTR), - ("lpTitle", wt.LPWSTR), ("dwX", wt.DWORD), ("dwY", wt.DWORD), - ("dwXSize", wt.DWORD), ("dwYSize", wt.DWORD), ("dwXCountChars", wt.DWORD), - ("dwYCountChars", wt.DWORD), ("dwFillAttribute", wt.DWORD), - ("dwFlags", wt.DWORD), ("wShowWindow", wt.WORD), ("cbReserved2", wt.WORD), - ("lpReserved2", ctypes.POINTER(wt.BYTE)), ("hStdInput", wt.HANDLE), - ("hStdOutput", wt.HANDLE), ("hStdError", wt.HANDLE), - ] - - -class StartupInfoEx(ctypes.Structure): - _fields_ = [("StartupInfo", StartupInfo), ("lpAttributeList", wt.LPVOID)] - - -class ProcessInfo(ctypes.Structure): - _fields_ = [("hProcess", wt.HANDLE), ("hThread", wt.HANDLE), - ("dwProcessId", wt.DWORD), ("dwThreadId", wt.DWORD)] - - -kernel = ctypes.WinDLL("kernel32", use_last_error=True) -kernel.CreatePipe.argtypes = [ctypes.POINTER(wt.HANDLE), ctypes.POINTER(wt.HANDLE), wt.LPVOID, wt.DWORD] -kernel.CreatePipe.restype = wt.BOOL -kernel.ReadFile.argtypes = [wt.HANDLE, wt.LPVOID, wt.DWORD, ctypes.POINTER(wt.DWORD), wt.LPVOID] -kernel.ReadFile.restype = wt.BOOL -kernel.WriteFile.argtypes = [wt.HANDLE, wt.LPCVOID, wt.DWORD, ctypes.POINTER(wt.DWORD), wt.LPVOID] -kernel.WriteFile.restype = wt.BOOL -kernel.CloseHandle.argtypes = [wt.HANDLE] -kernel.CloseHandle.restype = wt.BOOL -kernel.InitializeProcThreadAttributeList.argtypes = [wt.LPVOID, wt.DWORD, wt.DWORD, ctypes.POINTER(ctypes.c_size_t)] -kernel.InitializeProcThreadAttributeList.restype = wt.BOOL -kernel.UpdateProcThreadAttribute.argtypes = [wt.LPVOID, wt.DWORD, ctypes.c_size_t, wt.LPVOID, ctypes.c_size_t, wt.LPVOID, wt.LPVOID] -kernel.UpdateProcThreadAttribute.restype = wt.BOOL -kernel.DeleteProcThreadAttributeList.argtypes = [wt.LPVOID] -kernel.CreateProcessW.argtypes = [wt.LPCWSTR, wt.LPWSTR, wt.LPVOID, wt.LPVOID, wt.BOOL, wt.DWORD, wt.LPVOID, wt.LPCWSTR, wt.LPVOID, ctypes.POINTER(ProcessInfo)] -kernel.CreateProcessW.restype = wt.BOOL -kernel.WaitForSingleObject.argtypes = [wt.HANDLE, wt.DWORD] -kernel.WaitForSingleObject.restype = wt.DWORD -kernel.GetExitCodeProcess.argtypes = [wt.HANDLE, ctypes.POINTER(wt.DWORD)] -kernel.GetExitCodeProcess.restype = wt.BOOL -kernel.TerminateProcess.argtypes = [wt.HANDLE, wt.UINT] -kernel.TerminateProcess.restype = wt.BOOL -kernel.FreeConsole.argtypes = [] -kernel.FreeConsole.restype = wt.BOOL -kernel.AttachConsole.argtypes = [wt.DWORD] -kernel.AttachConsole.restype = wt.BOOL -kernel.CreateFileW.argtypes = [wt.LPCWSTR, wt.DWORD, wt.DWORD, wt.LPVOID, wt.DWORD, wt.DWORD, wt.HANDLE] -kernel.CreateFileW.restype = wt.HANDLE -kernel.GetConsoleScreenBufferInfo.argtypes = [wt.HANDLE, wt.LPVOID] -kernel.GetConsoleScreenBufferInfo.restype = wt.BOOL -INVALID_HANDLE_VALUE = wt.HANDLE(-1).value - - -def cursor_probe(pid: int) -> dict[str, object]: - """The same conhost cursor probe Pebrel runs after every resize.""" - kernel.FreeConsole() - if not kernel.AttachConsole(pid): - return {"attach_error": ctypes.get_last_error()} - handle = kernel.CreateFileW("CONOUT$", 0xC0000000, 0x3, None, 3, 0, None) - if handle == INVALID_HANDLE_VALUE: - error = ctypes.get_last_error() - kernel.FreeConsole() - return {"conout_error": error} - info = ctypes.create_string_buffer(22) - ok = kernel.GetConsoleScreenBufferInfo(handle, info) - error = ctypes.get_last_error() - kernel.CloseHandle(handle) - kernel.FreeConsole() - if not ok: - return {"info_error": error} - # CONSOLE_SCREEN_BUFFER_INFO: dwSize, dwCursorPosition, wAttributes, srWindow. - cursor_y = int.from_bytes(info.raw[6:8], "little", signed=True) - top = int.from_bytes(info.raw[12:14], "little", signed=True) - bottom = int.from_bytes(info.raw[16:18], "little", signed=True) - return {"row": cursor_y - top, "rows": bottom - top + 1} - -CreateFn = ctypes.WINFUNCTYPE(ctypes.c_long, Coord, wt.HANDLE, wt.HANDLE, wt.DWORD, ctypes.POINTER(wt.LPVOID)) -ResizeFn = ctypes.WINFUNCTYPE(ctypes.c_long, wt.LPVOID, Coord) -CloseFn = ctypes.WINFUNCTYPE(None, wt.LPVOID) - - -def api(library: str | None) -> tuple[CreateFn, ResizeFn, CloseFn]: - module = ctypes.WinDLL(library, use_last_error=True) if library else kernel - create = CreateFn(("CreatePseudoConsole", module)) - resize = ResizeFn(("ResizePseudoConsole", module)) - close = CloseFn(("ClosePseudoConsole", module)) - return create, resize, close - - -def run_case( - library: str | None, - command: str, - flags: int, - wait_seconds: float, - done_marker: bytes = b"CONPTY_SMOKE_OK", - steps: tuple[str, ...] = (), - env: dict[str, str] | None = None, - cwd: str | None = None, -) -> dict[str, object]: - """Interactive shells never exit; the marker ends the wait once it renders. - - `env` replaces the inherited block (CREATE_UNICODE_ENVIRONMENT) and `cwd` - the inherited directory, so Pebrel's own child environment can be replayed. - - `steps` replays Pebrel's post-spawn sequence 30ms after the first output: - `resize` calls ResizePseudoConsole, `probe` attaches to the client console - and reads its cursor, mirroring the align-sync and align stages. - """ - result: dict[str, object] = { - "library": library or "kernel32", "command": command, "flags": flags, "steps": list(steps), - } - try: - create, resize, close = api(library) - except OSError as error: - result["error"] = f"load failed: {error}" - return result - conout_read, conout_write = wt.HANDLE(), wt.HANDLE() - conin_read, conin_write = wt.HANDLE(), wt.HANDLE() - if not kernel.CreatePipe(ctypes.byref(conout_read), ctypes.byref(conout_write), None, 0) or \ - not kernel.CreatePipe(ctypes.byref(conin_read), ctypes.byref(conin_write), None, 0): - result["error"] = f"CreatePipe failed: {ctypes.get_last_error()}" - return result - hpc = wt.LPVOID() - started = time.monotonic() - hresult = create(Coord(80, 25), conin_read, conout_write, flags, ctypes.byref(hpc)) - result["create_hresult"] = f"{hresult & 0xFFFFFFFF:#010x}" - result["create_ms"] = round((time.monotonic() - started) * 1000) - if hresult != 0: - return result - # The side-loaded host answers the same primed DA1 reply Pebrel writes. - written = wt.DWORD() - kernel.WriteFile(conin_write, b"\x1b[?61c", 6, ctypes.byref(written), None) - - size = ctypes.c_size_t(0) - kernel.InitializeProcThreadAttributeList(None, 1, 0, ctypes.byref(size)) - attributes = ctypes.create_string_buffer(size.value) - if not kernel.InitializeProcThreadAttributeList(attributes, 1, 0, ctypes.byref(size)): - result["error"] = f"InitializeProcThreadAttributeList failed: {ctypes.get_last_error()}" - return result - if not kernel.UpdateProcThreadAttribute(attributes, 0, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, - hpc, ctypes.sizeof(wt.LPVOID), None, None): - result["error"] = f"UpdateProcThreadAttribute failed: {ctypes.get_last_error()}" - return result - startup = StartupInfoEx() - startup.StartupInfo.cb = ctypes.sizeof(startup) - # Null standard handles with USESTDHANDLES: the client binds to the - # pseudoconsole instead of inheriting this process's console or pipes. - startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES - startup.lpAttributeList = ctypes.cast(attributes, wt.LPVOID) - process = ProcessInfo() - cmdline = ctypes.create_unicode_buffer(command) - creation_flags = EXTENDED_STARTUPINFO_PRESENT - env_block = None - if env is not None: - creation_flags |= CREATE_UNICODE_ENVIRONMENT - block = "".join(f"{name}={value}\0" for name, value in sorted(env.items(), key=lambda item: item[0].upper())) - env_block = ctypes.create_unicode_buffer(block + "\0") - if not kernel.CreateProcessW(None, cmdline, None, None, False, creation_flags, - env_block, cwd, ctypes.byref(startup), ctypes.byref(process)): - result["error"] = f"CreateProcessW failed: {ctypes.get_last_error()}" - return result - result["pid"] = process.dwProcessId - - chunks: list[bytes] = [] - - def reader() -> None: - buffer = ctypes.create_string_buffer(4096) - read = wt.DWORD() - while kernel.ReadFile(conout_read, buffer, 4096, ctypes.byref(read), None) and read.value: - chunks.append(buffer.raw[: read.value]) - - thread = threading.Thread(target=reader, daemon=True) - thread.start() - deadline = time.monotonic() + wait_seconds - first_output_ms: int | None = None - marker_ms: int | None = None - steps_at: float | None = None - step_log: list[dict[str, object]] = [] - while time.monotonic() < deadline: - if chunks and first_output_ms is None: - first_output_ms = round((time.monotonic() - started) * 1000) - steps_at = time.monotonic() + 0.03 - if steps and steps_at is not None and time.monotonic() >= steps_at: - for step in steps: - entry: dict[str, object] = {"step": step, "at_ms": round((time.monotonic() - started) * 1000)} - if step == "resize": - entry["hresult"] = f"{resize(hpc, Coord(82, 25)) & 0xFFFFFFFF:#010x}" - elif step == "probe": - entry.update(cursor_probe(process.dwProcessId)) - entry["bytes_after"] = sum(len(chunk) for chunk in chunks) - step_log.append(entry) - steps = () - if marker_ms is None and done_marker in b"".join(chunks): - marker_ms = round((time.monotonic() - started) * 1000) - break - if kernel.WaitForSingleObject(process.hProcess, 100) == 0: - break - result["step_log"] = step_log - result["marker_ms"] = marker_ms - code = wt.DWORD() - kernel.GetExitCodeProcess(process.hProcess, ctypes.byref(code)) - if code.value == 259: # STILL_ACTIVE - kernel.TerminateProcess(process.hProcess, 1) - result["exit"] = "terminated after timeout" - else: - result["exit"] = code.value - # The in-box host flushes trailing output while ClosePseudoConsole drains - # the pipe; the reader thread keeps consuming during that call. - if conout_write is not None: - kernel.CloseHandle(conout_write) - close(hpc) - thread.join(2.0) - output = b"".join(chunks) - if chunks and first_output_ms is None: - first_output_ms = round((time.monotonic() - started) * 1000) - result["first_output_ms"] = first_output_ms - result["output_bytes"] = len(output) - result["output_head"] = output[:2000].decode("utf-8", "replace") - kernel.CloseHandle(process.hThread) - kernel.CloseHandle(process.hProcess) - kernel.DeleteProcThreadAttributeList(attributes) - for handle in (conin_write, conin_read, conout_read): - if handle is not None: - kernel.CloseHandle(handle) - return result - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--runtime", help="directory holding conpty.dll and OpenConsole.exe") - parser.add_argument("--wait", type=float, default=15.0) - parser.add_argument("--output") - parser.add_argument( - "--prompt-script", - help="also start Windows PowerShell interactively with this integration script, like Pebrel does", - ) - args = parser.parse_args() - # Terminal bytes include private-use glyphs that legacy code pages reject. - sys.stdout.reconfigure(encoding="utf-8", errors="replace") - - shells = [] - for name in ("pwsh", "powershell", "cmd"): - path = shutil.which(name) - if path: - shells.append((name, path)) - libraries: list[str | None] = [None] - if args.runtime: - libraries.insert(0, os.path.join(os.path.abspath(args.runtime), "conpty.dll")) - - report: dict[str, object] = { - "architecture": os.environ.get("PROCESSOR_ARCHITECTURE"), - "python": sys.version, - "shells": dict(shells), - "cases": [], - } - commands = { - "pwsh": '"{path}" -NoLogo -NoProfile -Command "Write-Output CONPTY_SMOKE_OK"', - "powershell": '"{path}" -NoLogo -NoProfile -Command "Write-Output CONPTY_SMOKE_OK"', - "cmd": '"{path}" /d /c "echo CONPTY_SMOKE_OK"', - } - for library in libraries: - for name, path in shells: - for flags in (PSEUDOCONSOLE_WIN32_INPUT_MODE, 0): - case = run_case(library, commands[name].format(path=path), flags, args.wait) - case["shell"] = name - print(json.dumps(case, ensure_ascii=False), flush=True) - report["cases"].append(case) - powershell = dict(shells).get("powershell") - if args.prompt_script and powershell: - # The prompt function emits OSC 133;A once PowerShell reaches its - # first interactive prompt; a bare interactive shell shows "PS ". - script = os.path.abspath(args.prompt_script) - interactive = { - "powershell-interactive": (f'"{powershell}" -NoLogo -NoExit', b"PS "), - "powershell-integration": ( - f'"{powershell}" -NoLogo -NoExit -ExecutionPolicy Bypass -Command ". \'{script}\'"', - b"133;A", - ), - } - variants: list[tuple[str, ...]] = [ - (), - ("resize", "probe", "probe"), - ("early_close",), - ("early_close", "resize", "probe", "probe"), - ] - for name, (command, marker) in interactive.items(): - for steps in variants: - case = run_case( - library, command, PSEUDOCONSOLE_WIN32_INPUT_MODE, args.wait, marker, steps, - ) - case["shell"] = name - print(json.dumps(case, ensure_ascii=False), flush=True) - report["cases"].append(case) - if args.output: - os.makedirs(os.path.dirname(os.path.abspath(args.output)), exist_ok=True) - with open(args.output, "w", encoding="utf-8") as handle: - json.dump(report, handle, ensure_ascii=False, indent=2) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/conformance/explicit_command_boot.py b/scripts/conformance/explicit_command_boot.py deleted file mode 100644 index 1b19277..0000000 --- a/scripts/conformance/explicit_command_boot.py +++ /dev/null @@ -1,249 +0,0 @@ -"""Boot Pebrel with explicit `-e` commands and dump what each pane renders. - -Separates "the shell hangs at startup" from "the PTY read path is dead": -cmd, PowerShell 5.1 and pwsh are launched through the same ConPTY and -environment path as the default tab, and every pane is read for 15s. - -The `cmd-env` case captures the exact environment block Pebrel hands its -children; PowerShell 5.1 is then replayed outside the application with that -block and/or that working directory, and the differing variables are bisected -when the block alone reproduces a silent start. -""" - -from __future__ import annotations - -import json -import os -import subprocess -import sys -import tempfile -import time -from pathlib import Path - -sys.path.insert(0, os.fspath(Path(__file__).resolve().parent.parent)) -sys.path.insert(0, os.fspath(Path(__file__).resolve().parent)) -import conpty_smoke # noqa: E402 -from conformance.harness import RuntimeClient # noqa: E402 - -MARKER = "PEBREL_SMOKE_OK" -POWERSHELL_51 = ["powershell.exe", "-NoLogo", "-NoProfile", "-NoExit", "-Command", f"Write-Output {MARKER}"] -REPLAY_WAIT = 10.0 - -app = Path(sys.argv[1]).resolve() -output = Path(sys.argv[2]) -runtime_library = app.parent / "runtime" / "conpty.dll" -report: dict[str, object] = {} -app_env: dict[str, str] | None = None -app_cwd: str | None = None - - -def boot(name: str, command: list[str], env_dump: Path | None = None) -> dict[str, object]: - global app_env - with tempfile.TemporaryDirectory(prefix="pebrel-cmd-", ignore_cleanup_errors=True) as root: - config = Path(root) / "config" - work = Path(root) / "work" - config.mkdir() - work.mkdir() - (config / "pebrel_settings.txt").write_text( - "keep_session=0\ntray=0\nrestore_session=0\nresume_ai=0\nfetch=0\n" - "auto_check_updates=0\nai_hooks=0\nwindowing_behavior=use_new\n", - encoding="utf-8", - ) - if env_dump is not None: - env_dump = Path(root) / env_dump - command = [*command, f"set > {env_dump}"] - env = dict(os.environ, PEBREL_CONFIG_DIR=os.fspath(config), NEBULA_CONFIG_DIR=os.fspath(config), NEBULA_BOOT_TRACE="1") - env.pop("PEBREL_RUNTIME_ENDPOINT", None) - log = open(Path(root) / "app.log", "wb") - process = subprocess.Popen( - [os.fspath(app), "--working-directory", os.fspath(work), "-e", *command], - cwd=work, env=env, stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT, - ) - entry: dict[str, object] = {"command": command} - port = config / "runtime.port" - deadline = time.monotonic() + 20 - client = None - while time.monotonic() < deadline and client is None: - if port.is_file(): - try: - client = RuntimeClient.from_port_file(port, timeout=5.0) - client.request("runtime.describe", timeout=1.0) - except Exception: - client = None - time.sleep(0.1) - if client is None: - entry["error"] = "runtime did not come up" - else: - text = "" - started = time.monotonic() - first_text_ms = None - while time.monotonic() - started < 15: - try: - snapshot = client.request("runtime.snapshot", timeout=5.0)["result"] - window = snapshot["windows"][0] - pane = window["tabs"][0]["panes"][0] - read = client.request( - "pane.read", {"window_id": window["id"], "pane_id": pane["id"], "lines": 40}, timeout=5.0 - )["result"] - text = read.get("text", "") - entry["exited"] = read.get("exited") - entry["cwd"] = pane.get("cwd") - if text.strip() and first_text_ms is None: - first_text_ms = round((time.monotonic() - started) * 1000) - if MARKER in text: - break - if env_dump is not None and env_dump.is_file() and env_dump.stat().st_size > 0: - time.sleep(1.0) - break - except Exception as error: - entry["read_error"] = str(error) - time.sleep(0.25) - entry["first_text_ms"] = first_text_ms - entry["marker"] = MARKER in text - entry["text"] = text.strip()[:600] - if env_dump is not None and env_dump.is_file(): - parsed: dict[str, str] = {} - for line in env_dump.read_text(encoding="mbcs", errors="replace").splitlines(): - name, separator, value = line.partition("=") - if separator and name and not name.startswith("="): - parsed[name] = value - if parsed: - # Windows names are case-insensitive; os.environ upper-cases them. - app_env = {name.upper(): value for name, value in parsed.items()} - entry["app_env_count"] = len(parsed) - # The shell keeps the work directory as its cwd; take the whole tree down. - subprocess.run(["taskkill", "/T", "/F", "/PID", str(process.pid)], capture_output=True) - process.wait(10) - log.close() - entry["app_log"] = [ - line for line in (Path(root) / "app.log").read_text(encoding="utf-8", errors="replace").splitlines() - if "prepaint" not in line and "resize-trace" not in line - ][:40] - print(name, json.dumps({k: v for k, v in entry.items() if k != "app_log"}, ensure_ascii=False)[:400], flush=True) - return entry - - -def replay(name: str, env: dict[str, str] | None, cwd: str | None) -> bool: - """Run PowerShell 5.1 through the smoke path; True when the marker rendered.""" - library = os.fspath(runtime_library) if runtime_library.is_file() else None - command = subprocess.list2cmdline(POWERSHELL_51) - result = conpty_smoke.run_case(library, command, 0, REPLAY_WAIT, done_marker=MARKER.encode(), env=env, cwd=cwd) - passed = result.get("marker_ms") is not None - summary = {k: result.get(k) for k in ("error", "first_output_ms", "marker_ms", "output_bytes")} - print("replay", name, "PASS" if passed else "SILENT", json.dumps(summary), flush=True) - report.setdefault("replays", {})[name] = summary | {"passed": passed} # type: ignore[index] - return passed - - -def bisect(base: dict[str, str], target: dict[str, str]) -> None: - """Find which of the variables that differ between base and target break PS 5.1.""" - differing = sorted({*base, *target}, key=str.upper) - differing = [name for name in differing if base.get(name) != target.get(name)] - report["env_diff"] = {name: {"runner": base.get(name), "app": target.get(name)} for name in differing} - print(f"bisect over {len(differing)} differing variables", flush=True) - - def with_changes(names: list[str]) -> dict[str, str]: - env = dict(base) - for name in names: - if name in target: - env[name] = target[name] - else: - env.pop(name, None) - return env - - if replay("runner-env-plus-all-diffs", with_changes(differing), None): - report["bisect"] = "applying every differing variable to the runner environment still passes" - return - suspects = list(differing) - rounds = 0 - while len(suspects) > 1 and rounds < 12: - rounds += 1 - half = suspects[: len(suspects) // 2] - if not replay(f"round{rounds}-first-half({len(half)})", with_changes(half), None): - suspects = half - continue - rest = suspects[len(suspects) // 2 :] - if not replay(f"round{rounds}-second-half({len(rest)})", with_changes(rest), None): - suspects = rest - continue - report["bisect"] = f"no single half reproduces; remaining suspects: {suspects}" - return - report["bisect"] = {"culprits": suspects, "values": {name: target.get(name) for name in suspects}} - print("bisect result:", report["bisect"], flush=True) - - -def redacted(env: dict[str, str] | None) -> dict[str, str] | None: - if env is None: - return None - secret = ("TOKEN", "SECRET", "PASSWORD", "_KEY") - return {name: ("" if any(part in name.upper() for part in secret) else value) for name, value in env.items()} - - -# Same shape as the panes' working directory (under TEMP), but it outlives boot(). -app_cwd = os.fspath(Path(tempfile.mkdtemp(prefix="pebrel-cmd-")) / "work") -os.mkdir(app_cwd) -report["cmd-env"] = boot("cmd-env", ["cmd.exe", "/d", "/k"], env_dump=Path("app-env.txt")) -report["powershell"] = boot("powershell", POWERSHELL_51) -report["runner_env"] = redacted(dict(os.environ)) -report["app_env"] = redacted(app_env) -if app_env is not None: - control = replay("inherited-env-inherited-cwd", None, None) - env_only = replay("app-env-inherited-cwd", app_env, None) - cwd_only = replay("inherited-env-app-cwd", None, app_cwd) - replay("app-env-app-cwd", app_env, app_cwd) - if control and not env_only: - bisect(dict(os.environ), app_env) - if isinstance(report.get("bisect"), dict) and report["bisect"]["culprits"] == ["PSMODULEPATH"]: # type: ignore[index] - # Which entry of the registry-only module path stalls PowerShell 5.1, - # and is the stall tied to the interactive host (PSReadLine import)? - runner_entries = [entry for entry in os.environ.get("PSMODULEPATH", "").split(";") if entry] - app_entries = [entry for entry in app_env.get("PSMODULEPATH", "").split(";") if entry] - windows_default = [ - entry for entry in app_entries - if entry.lower().startswith((r"c:\program files\windowspowershell\modules", r"c:\windows\system32")) - ] - pwsh_entries = [entry for entry in runner_entries if entry not in app_entries] - variants: dict[str, tuple[list[str], list[str]]] = { - "app-entries": (app_entries, POWERSHELL_51), - "pwsh-dirs-then-app-entries": (pwsh_entries + app_entries, POWERSHELL_51), - "windows-default-only": (windows_default, POWERSHELL_51), - "windows-default-non-interactive": ( - windows_default, - ["powershell.exe", "-NoLogo", "-NoProfile", "-Command", f"Write-Output {MARKER}"], - ), - "windows-default-import-psreadline-non-interactive": ( - windows_default, - ["powershell.exe", "-NoLogo", "-NoProfile", "-Command", f"Import-Module PSReadLine; Write-Output {MARKER}"], - ), - "windows-default-list-psreadline-non-interactive": ( - windows_default, - ["powershell.exe", "-NoLogo", "-NoProfile", "-Command", - f"Get-Module -ListAvailable PSReadLine | ForEach-Object {{ $_.Version.ToString() + ' ' + $_.Path }}; Write-Output {MARKER}"], - ), - } - variants["windows-default-import-utility-verbose"] = ( - windows_default, - ["powershell.exe", "-NoLogo", "-NoProfile", "-Command", - f"Import-Module Microsoft.PowerShell.Utility -Verbose; Write-Output {MARKER}"], - ) - for index, entry in enumerate(app_entries): - variants[f"app-entries-without-{index}"] = ([e for e in app_entries if e != entry], POWERSHELL_51) - for name, (entries, command) in variants.items(): - env = dict(os.environ) - env["PSMODULEPATH"] = ";".join(entries) - library = os.fspath(runtime_library) if runtime_library.is_file() else None - result = conpty_smoke.run_case( - library, subprocess.list2cmdline(command), 0, REPLAY_WAIT, done_marker=MARKER.encode(), env=env, cwd=None - ) - summary = {k: result.get(k) for k in ("error", "first_output_ms", "marker_ms", "output_bytes")} - summary["output_head"] = str(result.get("output_head", ""))[:1500] - summary["entries"] = entries - print("psmodulepath", name, "PASS" if result.get("marker_ms") is not None else "SILENT", json.dumps(summary)[:400], flush=True) - report.setdefault("psmodulepath_variants", {})[name] = summary # type: ignore[index] - if isinstance(report.get("env_diff"), dict): - report["env_diff"] = { - name: {side: redacted({name: value})[name] if value is not None else None for side, value in sides.items()} # type: ignore[index] - for name, sides in report["env_diff"].items() # type: ignore[union-attr] - } -output.parent.mkdir(parents=True, exist_ok=True) -output.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") From ffec205e011ba873c6ad788f6e1e25fb0e1dfb3c Mon Sep 17 00:00:00 2001 From: kuddev Date: Tue, 22 Sep 2026 21:12:19 +0800 Subject: [PATCH 17/20] feat(gpui): add the Fluxion AI sponsor page and Discord link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Home → Project and support gains a Sponsors row that opens an in-app page (logo on the left, bilingual introduction on the right) with a single sign-up link carrying the project's attribution parameters. Both READMEs add a matching Sponsors table and the community Discord invite. --- README.md | 17 +++ README.zh-CN.md | 17 +++ extra/logo/sponsor_fluxionai.png | Bin 0 -> 43233 bytes nebula_app/src/gpui_shell/settings_pane.rs | 5 + .../src/gpui_shell/settings_pane/about.rs | 36 ++++- .../settings_pane/initialization.rs | 1 + .../src/gpui_shell/settings_pane/sponsor.rs | 132 ++++++++++++++++++ 7 files changed, 205 insertions(+), 3 deletions(-) create mode 100644 extra/logo/sponsor_fluxionai.png create mode 100644 nebula_app/src/gpui_shell/settings_pane/sponsor.rs diff --git a/README.md b/README.md index 83acb9b..8fa89d0 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Stars Forks Last commit + Discord linux.do

@@ -48,6 +49,20 @@ OpenCode, Claude Code, and Codex in split panes

+## 💖 Sponsors + + + + + + +
+ Fluxion AI
+ Fluxion AI +
+ Thanks to Fluxion AI for sponsoring this project! Fluxion AI is one entry point for accessing and managing the world's leading AI models. It serves individual developers, technical teams and enterprises with a unified API; dynamic multi-route scheduling improves availability, and model performance, response time and cost stay transparent. Depending on the model and route, API calls can cost 40%–98% less than official or benchmark prices. Sign up via this link to receive $3 in API credit. +
+ ## One Workspace Pebrel (formerly Nebula) brings local shells, remote hosts, files, and AI command-line @@ -176,6 +191,8 @@ through the explicit `legacy-shell` feature. Email: [fickleheartedkeys@163.com](mailto:fickleheartedkeys@163.com) +Discord: [discord.gg/VFn4rcxmhn](https://discord.gg/VFn4rcxmhn) + ## Acknowledgements Pebrel builds on [Alacritty](https://github.com/alacritty/alacritty), diff --git a/README.zh-CN.md b/README.zh-CN.md index 0f864b1..1aec735 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -27,6 +27,7 @@ Stars Forks 最近提交 + Discord linux.do

@@ -48,6 +49,20 @@ OpenCode、Claude Code 与 Codex 分屏工作流

+## 💖 赞助商 + + + + + + +
+ Fluxion AI
+ Fluxion AI +
+ 感谢 Fluxion AI 赞助本项目!Fluxion AI 是一个入口,接入并管理全球主流 AI 模型:面向个人开发者、技术团队与企业,通过统一 API 接入并管理全球主流 AI 模型;通过多线路动态调度提升可用性,模型表现、响应时间与费用透明可查。根据不同模型与线路,API 调用成本较官方或基准价格可降低 40%—98%。立即访问并注册,即可获得 $3 API 额度。 +
+ ## 一个工作区 Pebrel(原名 Nebula)把本地 Shell、远程主机、文件和 AI 命令行工具放进同一个原生桌面工作区。 @@ -158,6 +173,8 @@ cargo build --release --locked -p nebula --bin pebrel --features gpui-shell 邮箱:[fickleheartedkeys@163.com](mailto:fickleheartedkeys@163.com) +Discord:[discord.gg/VFn4rcxmhn](https://discord.gg/VFn4rcxmhn) + ## 致谢 Pebrel 基于 [Alacritty](https://github.com/alacritty/alacritty)、 diff --git a/extra/logo/sponsor_fluxionai.png b/extra/logo/sponsor_fluxionai.png new file mode 100644 index 0000000000000000000000000000000000000000..4eef0897e1c8d422f1c481d1d31d3fe11b10dfa8 GIT binary patch literal 43233 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, + /// 首页「项目与支持」→ 赞助商:独立页面,不是外链行。切换分区时清掉。 + about_sponsor_open: bool, settings_search_input: Entity, search_origin_section: Option, /// 每项还带着自己的 `values` 表:`SelectState` 只认索引,而从代码侧 @@ -1295,6 +1298,7 @@ impl SettingsPane { .into_any_element(); } match self.active_section { + 0 if self.about_sponsor_open => self.section_sponsor(cx), 0 => self.section_home(window, cx), 1 => self.section_appearance(window, cx), 2 => self.section_profiles(window, cx), @@ -1379,6 +1383,7 @@ impl SettingsPane { .when(!active, |item| item.text_color(muted).hover(|s| s.bg(hover_bg))) .on_click(cx.listener(move |this, _, _, cx| { this.active_section = ix; + this.about_sponsor_open = false; cx.notify(); })) .child( diff --git a/nebula_app/src/gpui_shell/settings_pane/about.rs b/nebula_app/src/gpui_shell/settings_pane/about.rs index 5d2b681..1826d76 100644 --- a/nebula_app/src/gpui_shell/settings_pane/about.rs +++ b/nebula_app/src/gpui_shell/settings_pane/about.rs @@ -8,6 +8,31 @@ impl SettingsPane { url: String, cx: &Context, ) -> gpui::AnyElement { + Self::about_row(id, icon, title, IconName::ExternalLink, cx) + .on_click(move |_, _, cx| cx.open_url(&url)) + .into_any_element() + } + + /// 与外链行同一条骨架,尾标是「进入页面」而不是「离开应用」。 + pub(super) fn about_page_row( + id: &'static str, + icon: IconName, + title: &'static str, + on_click: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static, + cx: &Context, + ) -> gpui::AnyElement { + Self::about_row(id, icon, title, IconName::ChevronRight, cx) + .on_click(on_click) + .into_any_element() + } + + fn about_row( + id: &'static str, + icon: IconName, + title: &'static str, + trailing: IconName, + cx: &Context, + ) -> gpui::Stateful { let muted = cx.theme().muted_foreground; let hover = cx.theme().list_hover; h_flex() @@ -21,11 +46,9 @@ impl SettingsPane { .rounded_md() .cursor_pointer() .hover(move |row| row.bg(hover)) - .on_click(move |_, _, cx| cx.open_url(&url)) .child(Icon::new(icon).small().text_color(muted)) .child(div().flex_1().min_w_0().child(title)) - .child(Icon::new(IconName::ExternalLink).xsmall().text_color(muted)) - .into_any_element() + .child(Icon::new(trailing).xsmall().text_color(muted)) } pub(super) fn about_value_row( @@ -252,6 +275,13 @@ impl SettingsPane { language.pick("更新内容", "Release notes"), crate::update_check::RELEASES_PAGE.to_owned(), cx, + )) + .child(Self::about_page_row( + "about-sponsors", + IconName::Heart, + language.pick("赞助商", "Sponsors"), + cx.listener(|this, _, _, cx| this.open_sponsor_page(cx)), + cx, )); v_flex().w_full().child(identity).child( diff --git a/nebula_app/src/gpui_shell/settings_pane/initialization.rs b/nebula_app/src/gpui_shell/settings_pane/initialization.rs index d972d16..1ff1abe 100644 --- a/nebula_app/src/gpui_shell/settings_pane/initialization.rs +++ b/nebula_app/src/gpui_shell/settings_pane/initialization.rs @@ -584,6 +584,7 @@ impl SettingsPane { about_update: AboutUpdateState::Idle, about_update_seq: 0, about_last_checked: None, + about_sponsor_open: false, settings_search_input, search_origin_section: None, selects, diff --git a/nebula_app/src/gpui_shell/settings_pane/sponsor.rs b/nebula_app/src/gpui_shell/settings_pane/sponsor.rs new file mode 100644 index 0000000..b603b7c --- /dev/null +++ b/nebula_app/src/gpui_shell/settings_pane/sponsor.rs @@ -0,0 +1,132 @@ +//! 设置首页「项目与支持」下的赞助商页:左侧品牌贴图、右侧介绍与注册链接。 +//! +//! 不做成外链行:外链行一点就跳浏览器,赞助商介绍在应用里就该读得到; +//! 页面只提供一条明确的注册链接,点它才离开应用。 + +use std::sync::{Arc, OnceLock}; + +use super::*; + +/// 注册链接。`source` / `campaign` / `promo` 三个参数是赞助商给本项目的 +/// 专属归因,README 与应用内必须用同一条,不能各写各的。 +pub(super) const SPONSOR_URL: &str = + "https://fluxionai.space/register?source=github&campaign=pebrel&promo=pebrel"; +const SPONSOR_NAME: &str = "Fluxion AI"; +const LOGO_PNG: &[u8] = include_bytes!("../../../../extra/logo/sponsor_fluxionai.png"); +/// 贴图自带白底圆角,深浅主题都读得清品牌字。 +const LOGO_WIDTH: f32 = 188.0; + +/// 贴图只解码一次:RGBA8 → BGRA(gpui 帧通道序),与壁纸 / 文档图同款。 +fn sponsor_logo() -> Option> { + static LOGO: OnceLock>> = OnceLock::new(); + LOGO.get_or_init(|| { + let mut rgba = image::load_from_memory(LOGO_PNG).ok()?.into_rgba8(); + for pixel in rgba.chunks_exact_mut(4) { + pixel.swap(0, 2); + } + Some(Arc::new(RenderImage::new([image::Frame::new(rgba)]))) + }) + .clone() +} + +impl SettingsPane { + pub(super) fn open_sponsor_page(&mut self, cx: &mut Context) { + self.about_sponsor_open = true; + cx.notify(); + } + + pub(super) fn close_sponsor_page(&mut self, cx: &mut Context) { + self.about_sponsor_open = false; + cx.notify(); + } + + pub(super) fn section_sponsor(&mut self, cx: &mut Context) -> gpui::Div { + let language = crate::gpui_shell::config::ui_language(cx); + let theme = cx.theme(); + let muted = theme.muted_foreground; + let ink = theme.foreground; + let link = theme.primary; + let hover = theme.list_hover; + let base_px = self.font_size_px(cx); + + let back = h_flex() + .id("sponsor-back") + .w_auto() + .h(px(32.0)) + .px_2() + .gap_2() + .items_center() + .rounded_md() + .cursor_pointer() + .text_color(muted) + .hover(move |row| row.bg(hover).text_color(ink)) + .on_click(cx.listener(|this, _, _, cx| this.close_sponsor_page(cx))) + .child(Icon::new(IconName::ArrowLeft).small()) + .child(language.pick("返回", "Back")); + + let logo = + div().w(px(LOGO_WIDTH)).flex_shrink_0().when_some(sponsor_logo(), |slot, image| { + slot.child(gpui::StyledImage::object_fit( + img(image).w(px(LOGO_WIDTH)).h(px(LOGO_WIDTH * 276.0 / 376.0)), + gpui::ObjectFit::Contain, + )) + }); + + let register = h_flex() + .id("sponsor-register") + .w_auto() + .mt(px(4.0)) + .gap_1() + .items_center() + .cursor_pointer() + .text_color(link) + .hover(|row| row.underline()) + .on_click(|_, _, cx| cx.open_url(SPONSOR_URL)) + .child(language.pick( + "立即访问并注册,即可获得 $3 API 额度", + "Sign up now to receive $3 in API credit", + )) + .child(Icon::new(IconName::ExternalLink).xsmall()); + + let copy = v_flex() + .flex_1() + .min_w_0() + .gap(px(10.0)) + .child( + div() + .text_size(px(base_px * 1.3)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(ink) + .child(SPONSOR_NAME), + ) + .child(div().text_color(ink).child(language.pick( + "一个入口,接入并管理全球主流 AI 模型", + "One entry point to access and manage the world's leading AI models", + ))) + .child(div().text_color(muted).line_height(px(base_px * 1.6)).child(language.pick( + "Fluxion AI 面向个人开发者、技术团队与企业,通过统一 API 接入并管理全球主流 AI 模型;\ + 通过多线路动态调度提升可用性,模型表现、响应时间与费用透明可查。\ + 根据不同模型与线路,API 调用成本较官方或基准价格可降低 40%—98%。", + "Fluxion AI serves individual developers, technical teams and enterprises with a \ + unified API for accessing and managing mainstream AI models worldwide. Dynamic \ + multi-route scheduling improves availability, and model performance, response \ + time and cost stay transparent. Depending on the model and route, API calls can \ + cost 40%–98% less than official or benchmark prices.", + ))) + .child(register); + + v_flex() + .w_full() + .gap(px(20.0)) + .child(back) + .child( + div() + .h(px(30.0)) + .text_size(px(base_px * 0.85)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(muted) + .child(language.pick("赞助商", "Sponsors")), + ) + .child(h_flex().w_full().items_start().gap(px(28.0)).child(logo).child(copy)) + } +} From 102b9dc58a20e6a231c9e53cf5daa06a9aa0e790 Mon Sep 17 00:00:00 2001 From: kuddev Date: Tue, 22 Sep 2026 21:14:58 +0800 Subject: [PATCH 18/20] release: finalize Pebrel 1.9.0 notes and drop the ARM64 diagnostic probes Align the 1.9.0 notes and CHANGELOG entry with the 1.8.2 format (prefixed bilingual entries), record the session-breaker fix and the sponsor page, and date the entry to the release day. Remove the ARM64 diagnostics workflow and its two probe scripts from the release tree; the CI workaround script stays because the release workflow runs it. --- CHANGELOG.md | 74 ++++++++++--------- docs/release-notes/v1.9.0.md | 72 +++++++++--------- .../conformance/windows_arm64_module_path.ps1 | 3 +- 3 files changed, 79 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 045446e..3625135 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ Every release entry is provided in English and Simplified Chinese. 每个版本条目均同时提供英文和简体中文说明。 -## 1.9.0 - 2026-09-21 +## 1.9.0 - 2026-09-22 ### English @@ -15,12 +15,14 @@ Every release entry is provided in English and Simplified Chinese. - Added a dedicated Agents settings page for Claude Code, Codex, OpenCode, Cursor Agent, Kimi Code, Pi, Oh My Pi, GitHub Copilot and Grok. It shows detected CLI paths and actual Hook installation status beside each integration switch. Automatic Hook management is available on Windows. - Added configurable notification duration: keep the existing defaults, choose 5, 10, 30 or 90 seconds, or keep notifications visible until dismissed. The selected duration takes precedence over an Agent's default banner lifetime. - Added a persistent terminal font-ligature setting, enabled by default and applied to open sessions without restarting them. Addresses [#220](https://github.com/Kuddev/pebrel/issues/220). +- Added a Sponsors page under Settings → Home → Project and support, introducing Fluxion AI with its sign-up link. #### Fixed - Fixed Codex cold restoration using a Hook group identifier instead of the native conversation identity. Saved sessions with an older or unrecognized transcript filename retain their existing valid conversation ID. - Fixed missing Windows local Codex identities by recovering the native conversation ID and file path from the running process. Later lifecycle-only events retain the verified identity. - Fixed dead-end Codex restoration when a saved conversation no longer exists: recovery opens the native conversation chooser, including from CMD, and the tab menu offers an explicit conversation choice. +- Fixed the session crash-loop breaker counting a launch as failed while a restored AI conversation was still unconfirmed. Normal exits with a Codex conversation waiting at its startup prompts no longer quarantine the whole workspace after three launches, and the saved conversation target is retained for the next launch. - Fixed shutdown getting stuck when some AI identities cannot be collected. Users can save the known state and exit; save errors and subsequently changed editor drafts still keep the application open. - Fixed AI failures and cancellations appearing as successful completion. Claude failure events and Pi retry-aware outcomes now use the shared result handling. - Fixed notification replacements retaining an old result or expiry deadline. A refreshed pane result uses the selected duration, and an older timer cannot dismiss its replacement. @@ -57,46 +59,48 @@ Windows x64 includes an installer and portable ZIP; Windows ARM64 includes a nat #### 新增 -- 新增 Windows ARM64 原生便携 ZIP,与 Windows x64 安装器及便携 ZIP 一同提供。ARM64 使用原生运行器构建,并配套 ARM64 Hook 辅助程序与控制台运行时。 -- 新增 Kimi Code 识别、原生会话命令和受管理的生命周期 Hook,包含品牌图标及终端活动状态。 -- 新增独立 Agents 设置页,覆盖 Claude Code、Codex、OpenCode、Cursor Agent、Kimi Code、Pi、Oh My Pi、GitHub Copilot 和 Grok。每项显示检测到的 CLI 路径,并在开关旁显示实际 Hook 安装状态;自动 Hook 管理适用于 Windows。 -- 新增通知停留时长设置:保持既有默认值,或选择 5、10、30、90 秒以及手动关闭。用户选择的时长优先于 Agent 自带的横幅默认时长。 -- 新增可持久保存的终端字体连字开关,默认开启,并可应用于已打开的会话,无需重启终端。对应 [#220](https://github.com/Kuddev/pebrel/issues/220)。 +- 新增:Windows ARM64 原生便携 ZIP,与 Windows x64 安装器及便携 ZIP 一同提供。ARM64 使用原生运行器构建,并配套 ARM64 Hook 辅助程序与控制台运行时。 +- 新增:Kimi Code 识别、原生会话命令和受管理的生命周期 Hook,包含品牌图标及终端活动状态。 +- 新增:独立 Agents 设置页,覆盖 Claude Code、Codex、OpenCode、Cursor Agent、Kimi Code、Pi、Oh My Pi、GitHub Copilot 和 Grok。每项显示检测到的 CLI 路径,并在开关旁显示实际 Hook 安装状态;自动 Hook 管理适用于 Windows。 +- 新增:通知停留时长设置:保持既有默认值,或选择 5、10、30、90 秒以及手动关闭。用户选择的时长优先于 Agent 自带的横幅默认时长。 +- 新增:可持久保存的终端字体连字开关,默认开启,并可应用于已打开的会话,无需重启终端。对应 [#220](https://github.com/Kuddev/pebrel/issues/220)。 +- 新增:设置首页「项目与支持」中的赞助商页面,介绍赞助商 Fluxion AI 并提供注册链接。 #### 修复 -- 修复 Codex 冷恢复误用 Hook 分组标识而非原生对话身份的问题。旧快照中的会话文件名无法识别时,保留原有有效对话 ID。 -- 修复 Windows 本地 Codex 身份缺失的问题,可从运行中的进程找回原生对话 ID 和文件路径;后续仅包含生命周期信息的事件会保留已核验身份。 -- 修复保存的 Codex 对话不存在时恢复流程无法继续的问题:包括 CMD 在内,可打开原生会话选择器,标签菜单也提供主动选择对话的入口。 -- 修复部分 AI 身份无法取得时关闭流程卡住的问题。用户可以保存已知状态并退出;保存失败或编辑器草稿随后发生变化时仍保持应用打开。 -- 修复 AI 失败和取消事件被显示为成功完成的问题,Claude 失败事件及 Pi 重试后的结果统一进入通知结果处理。 -- 修复通知替换后残留旧结果或旧到期时间的问题。新的窗格结果遵循选定时长,旧计时器不能关闭替换后的通知。 -- 修复受支持 AI 确认动作:发送前检查请求仍然有效,并完成必要的按下、释放按键序列;Windows 通知激活时恢复对应应用窗口。 -- 修复 WSL Hook 安装与事件传递,使用发行版自身的用户配置;命令补全同步包含发行版和用户选项。 -- 修复继承的 Ctrl+C 忽略状态导致 CMD 子命令无法中断的问题,同时保留选区复制。 -- 修复原生 CMD 命令活动检测、多行输入以及等待折行边界的回显文本跟踪。 -- 修复应用的 256 色块被终端提示符颜色覆盖的问题,保留明确配置的自定义颜色覆盖。 -- 修复已保存 SSH 主机在自动认证时忽略可用系统 SSH 代理的问题。 -- 修复终端工作目录复制,改为复制 Shell 上报的路径,包括访客环境路径;普通选区复制保留原始文字。 -- 修复终端和回答阅读页打开受支持的本地 Markdown 链接、文件 URI 及相对路径的行为。含空格、中文或括号的路径按原窗格目录解析,路径缺失时提供可见反馈。对应 [#203](https://github.com/Kuddev/pebrel/issues/203)。 -- 修复文件编辑器选区与查找命中融入浅色、深色背景的问题;普通文本和代码编辑器可透出已配置的工作区背景图,光标所在行跟随主题。 -- 修复 Markdown 目录长标题压住相邻行的问题,完整标题仍可通过悬停提示和复制取得。对应 [#184](https://github.com/Kuddev/pebrel/issues/184)。 -- 修复 Markdown 拖选被中断后,未按住鼠标移动仍继续扩大选区的问题。 -- 修复代码语言菜单在窄窗口中越界的问题,并保持搜索框和键盘操作可用。 -- 修复 SSH 主机图标因字体可见轮廓与排版宽度不同而偏离中心的问题。 -- 移除拖动标签时重复出现的标题预览。 +- 修复:Codex 冷恢复误用 Hook 分组标识而非原生对话身份的问题。旧快照中的会话文件名无法识别时,保留原有有效对话 ID。 +- 修复:Windows 本地 Codex 身份缺失的问题,可从运行中的进程找回原生对话 ID 和文件路径;后续仅包含生命周期信息的事件会保留已核验身份。 +- 修复:保存的 Codex 对话不存在时恢复流程无法继续的问题:包括 CMD 在内,可打开原生会话选择器,标签菜单也提供主动选择对话的入口。 +- 修复:已恢复的 AI 对话尚未确认时,本次启动被会话断路器误计为失败的问题。Codex 对话停在启动提示上、正常退出三次后,不再把整个工作区隔离到 `session.crashed.json`;已保存的对话目标保留至下次启动。 +- 修复:部分 AI 身份无法取得时关闭流程卡住的问题。用户可以保存已知状态并退出;保存失败或编辑器草稿随后发生变化时仍保持应用打开。 +- 修复:AI 失败和取消事件被显示为成功完成的问题,Claude 失败事件及 Pi 重试后的结果统一进入通知结果处理。 +- 修复:通知替换后残留旧结果或旧到期时间的问题。新的窗格结果遵循选定时长,旧计时器不能关闭替换后的通知。 +- 修复:受支持 AI 确认动作:发送前检查请求仍然有效,并完成必要的按下、释放按键序列;Windows 通知激活时恢复对应应用窗口。 +- 修复:WSL Hook 安装与事件传递,使用发行版自身的用户配置;命令补全同步包含发行版和用户选项。 +- 修复:继承的 Ctrl+C 忽略状态导致 CMD 子命令无法中断的问题,同时保留选区复制。 +- 修复:原生 CMD 命令活动检测、多行输入以及等待折行边界的回显文本跟踪。 +- 修复:应用的 256 色块被终端提示符颜色覆盖的问题,保留明确配置的自定义颜色覆盖。 +- 修复:已保存 SSH 主机在自动认证时忽略可用系统 SSH 代理的问题。 +- 修复:终端工作目录复制,改为复制 Shell 上报的路径,包括访客环境路径;普通选区复制保留原始文字。 +- 修复:终端和回答阅读页打开受支持的本地 Markdown 链接、文件 URI 及相对路径的行为。含空格、中文或括号的路径按原窗格目录解析,路径缺失时提供可见反馈。对应 [#203](https://github.com/Kuddev/pebrel/issues/203)。 +- 修复:文件编辑器选区与查找命中融入浅色、深色背景的问题;普通文本和代码编辑器可透出已配置的工作区背景图,光标所在行跟随主题。 +- 修复:Markdown 目录长标题压住相邻行的问题,完整标题仍可通过悬停提示和复制取得。对应 [#184](https://github.com/Kuddev/pebrel/issues/184)。 +- 修复:Markdown 拖选被中断后,未按住鼠标移动仍继续扩大选区的问题。 +- 修复:代码语言菜单在窄窗口中越界的问题,并保持搜索框和键盘操作可用。 +- 修复:SSH 主机图标因字体可见轮廓与排版宽度不同而偏离中心的问题。 +- 修复:移除拖动标签时重复出现的标题预览。 #### 改进 -- Markdown 默认以只读方式渲染,保留文字选择、复制、代码复制及公式操作;编辑需要显式切换到源码模式。 -- 阅读模式仍可选择代码块语言,只改变语法高亮,不改写 Markdown;滚动离开再返回后保留选择,复制得到的代码内容不变。 -- 从源码模式返回阅读页时保留草稿,同时阻止只读模式下的撤销、重做快捷键修改文档。 -- Markdown 使用虚拟列表、可见代码块和图片资源管理,并释放非活动文档的布局缓存,减少重复工作;不把单机结果写成固定的内存或速度承诺。 -- Git/SVN 抽屉复用仓库快照,仅渲染可见条目,减少大型仓库中的重复工作。 -- Agents 页面整体居中,九种 Agent 图标使用放大的 SVG 资源;CLI 是否存在与 Hook 是否安装分别展示,长路径可通过悬停提示查看。 -- 改进设置搜索、SSH 连接卡片和筛选、文件面板控件对齐,以及命令分组的导航、排序和新增操作。 -- 安装或移除受管理的 Agent Hook 时,保留其他集成及用户已有配置。 -- 暂时隐藏设置侧栏和搜索中的备份入口,本版不开放云备份控件。 +- 改进:Markdown 默认以只读方式渲染,保留文字选择、复制、代码复制及公式操作;编辑需要显式切换到源码模式。 +- 改进:阅读模式仍可选择代码块语言,只改变语法高亮,不改写 Markdown;滚动离开再返回后保留选择,复制得到的代码内容不变。 +- 改进:从源码模式返回阅读页时保留草稿,同时阻止只读模式下的撤销、重做快捷键修改文档。 +- 改进:Markdown 使用虚拟列表、可见代码块和图片资源管理,并释放非活动文档的布局缓存,减少重复工作;不把单机结果写成固定的内存或速度承诺。 +- 改进:Git/SVN 抽屉复用仓库快照,仅渲染可见条目,减少大型仓库中的重复工作。 +- 改进:Agents 页面整体居中,九种 Agent 图标使用放大的 SVG 资源;CLI 是否存在与 Hook 是否安装分别展示,长路径可通过悬停提示查看。 +- 改进:设置搜索、SSH 连接卡片和筛选、文件面板控件对齐,以及命令分组的导航、排序和新增操作。 +- 改进:安装或移除受管理的 Agent Hook 时,保留其他集成及用户已有配置。 +- 改进:暂时隐藏设置侧栏和搜索中的备份入口,本版不开放云备份控件。 Windows x64 提供安装器和 ZIP 便携包,Windows ARM64 提供原生便携 ZIP;安装器和应用内自动安装仍仅支持 x64。Linux x64 包及 macOS Apple Silicon/Intel DMG 继续标记为 Preview。macOS 最低版本为 14。分子结构渲染仍处于禁用状态。 diff --git a/docs/release-notes/v1.9.0.md b/docs/release-notes/v1.9.0.md index 88eaf8a..175094f 100644 --- a/docs/release-notes/v1.9.0.md +++ b/docs/release-notes/v1.9.0.md @@ -9,12 +9,14 @@ - Added a dedicated Agents settings page for Claude Code, Codex, OpenCode, Cursor Agent, Kimi Code, Pi, Oh My Pi, GitHub Copilot and Grok. It shows detected CLI paths and actual Hook installation status beside each integration switch. Automatic Hook management is available on Windows. - Added configurable notification duration: keep the existing defaults, choose 5, 10, 30 or 90 seconds, or keep notifications visible until dismissed. The selected duration takes precedence over an Agent's default banner lifetime. - Added a persistent terminal font-ligature setting, enabled by default and applied to open sessions without restarting them. Addresses [#220](https://github.com/Kuddev/pebrel/issues/220). +- Added a Sponsors page under Settings → Home → Project and support, introducing Fluxion AI with its sign-up link. ### Fixed - Fixed Codex cold restoration using a Hook group identifier instead of the native conversation identity. Saved sessions with an older or unrecognized transcript filename retain their existing valid conversation ID. - Fixed missing Windows local Codex identities by recovering the native conversation ID and file path from the running process. Later lifecycle-only events retain the verified identity. - Fixed dead-end Codex restoration when a saved conversation no longer exists: recovery opens the native conversation chooser, including from CMD, and the tab menu offers an explicit conversation choice. +- Fixed the session crash-loop breaker counting a launch as failed while a restored AI conversation was still unconfirmed. Normal exits with a Codex conversation waiting at its startup prompts no longer quarantine the whole workspace after three launches, and the saved conversation target is retained for the next launch. - Fixed shutdown getting stuck when some AI identities cannot be collected. Users can save the known state and exit; save errors and subsequently changed editor drafts still keep the application open. - Fixed AI failures and cancellations appearing as successful completion. Claude failure events and Pi retry-aware outcomes now use the shared result handling. - Fixed notification replacements retaining an old result or expiry deadline. A refreshed pane result uses the selected duration, and an older timer cannot dismiss its replacement. @@ -51,46 +53,48 @@ Windows x64 includes an installer and portable ZIP; Windows ARM64 includes a nat ### 新增 -- 新增 Windows ARM64 原生便携 ZIP,与 Windows x64 安装器及便携 ZIP 一同提供。ARM64 使用原生运行器构建,并配套 ARM64 Hook 辅助程序与控制台运行时。 -- 新增 Kimi Code 识别、原生会话命令和受管理的生命周期 Hook,包含品牌图标及终端活动状态。 -- 新增独立 Agents 设置页,覆盖 Claude Code、Codex、OpenCode、Cursor Agent、Kimi Code、Pi、Oh My Pi、GitHub Copilot 和 Grok。每项显示检测到的 CLI 路径,并在开关旁显示实际 Hook 安装状态;自动 Hook 管理适用于 Windows。 -- 新增通知停留时长设置:保持既有默认值,或选择 5、10、30、90 秒以及手动关闭。用户选择的时长优先于 Agent 自带的横幅默认时长。 -- 新增可持久保存的终端字体连字开关,默认开启,并可应用于已打开的会话,无需重启终端。对应 [#220](https://github.com/Kuddev/pebrel/issues/220)。 +- 新增:Windows ARM64 原生便携 ZIP,与 Windows x64 安装器及便携 ZIP 一同提供。ARM64 使用原生运行器构建,并配套 ARM64 Hook 辅助程序与控制台运行时。 +- 新增:Kimi Code 识别、原生会话命令和受管理的生命周期 Hook,包含品牌图标及终端活动状态。 +- 新增:独立 Agents 设置页,覆盖 Claude Code、Codex、OpenCode、Cursor Agent、Kimi Code、Pi、Oh My Pi、GitHub Copilot 和 Grok。每项显示检测到的 CLI 路径,并在开关旁显示实际 Hook 安装状态;自动 Hook 管理适用于 Windows。 +- 新增:通知停留时长设置:保持既有默认值,或选择 5、10、30、90 秒以及手动关闭。用户选择的时长优先于 Agent 自带的横幅默认时长。 +- 新增:可持久保存的终端字体连字开关,默认开启,并可应用于已打开的会话,无需重启终端。对应 [#220](https://github.com/Kuddev/pebrel/issues/220)。 +- 新增:设置首页「项目与支持」中的赞助商页面,介绍赞助商 Fluxion AI 并提供注册链接。 ### 修复 -- 修复 Codex 冷恢复误用 Hook 分组标识而非原生对话身份的问题。旧快照中的会话文件名无法识别时,保留原有有效对话 ID。 -- 修复 Windows 本地 Codex 身份缺失的问题,可从运行中的进程找回原生对话 ID 和文件路径;后续仅包含生命周期信息的事件会保留已核验身份。 -- 修复保存的 Codex 对话不存在时恢复流程无法继续的问题:包括 CMD 在内,可打开原生会话选择器,标签菜单也提供主动选择对话的入口。 -- 修复部分 AI 身份无法取得时关闭流程卡住的问题。用户可以保存已知状态并退出;保存失败或编辑器草稿随后发生变化时仍保持应用打开。 -- 修复 AI 失败和取消事件被显示为成功完成的问题,Claude 失败事件及 Pi 重试后的结果统一进入通知结果处理。 -- 修复通知替换后残留旧结果或旧到期时间的问题。新的窗格结果遵循选定时长,旧计时器不能关闭替换后的通知。 -- 修复受支持 AI 确认动作:发送前检查请求仍然有效,并完成必要的按下、释放按键序列;Windows 通知激活时恢复对应应用窗口。 -- 修复 WSL Hook 安装与事件传递,使用发行版自身的用户配置;命令补全同步包含发行版和用户选项。 -- 修复继承的 Ctrl+C 忽略状态导致 CMD 子命令无法中断的问题,同时保留选区复制。 -- 修复原生 CMD 命令活动检测、多行输入以及等待折行边界的回显文本跟踪。 -- 修复应用的 256 色块被终端提示符颜色覆盖的问题,保留明确配置的自定义颜色覆盖。 -- 修复已保存 SSH 主机在自动认证时忽略可用系统 SSH 代理的问题。 -- 修复终端工作目录复制,改为复制 Shell 上报的路径,包括访客环境路径;普通选区复制保留原始文字。 -- 修复终端和回答阅读页打开受支持的本地 Markdown 链接、文件 URI 及相对路径的行为。含空格、中文或括号的路径按原窗格目录解析,路径缺失时提供可见反馈。对应 [#203](https://github.com/Kuddev/pebrel/issues/203)。 -- 修复文件编辑器选区与查找命中融入浅色、深色背景的问题;普通文本和代码编辑器可透出已配置的工作区背景图,光标所在行跟随主题。 -- 修复 Markdown 目录长标题压住相邻行的问题,完整标题仍可通过悬停提示和复制取得。对应 [#184](https://github.com/Kuddev/pebrel/issues/184)。 -- 修复 Markdown 拖选被中断后,未按住鼠标移动仍继续扩大选区的问题。 -- 修复代码语言菜单在窄窗口中越界的问题,并保持搜索框和键盘操作可用。 -- 修复 SSH 主机图标因字体可见轮廓与排版宽度不同而偏离中心的问题。 -- 移除拖动标签时重复出现的标题预览。 +- 修复:Codex 冷恢复误用 Hook 分组标识而非原生对话身份的问题。旧快照中的会话文件名无法识别时,保留原有有效对话 ID。 +- 修复:Windows 本地 Codex 身份缺失的问题,可从运行中的进程找回原生对话 ID 和文件路径;后续仅包含生命周期信息的事件会保留已核验身份。 +- 修复:保存的 Codex 对话不存在时恢复流程无法继续的问题:包括 CMD 在内,可打开原生会话选择器,标签菜单也提供主动选择对话的入口。 +- 修复:已恢复的 AI 对话尚未确认时,本次启动被会话断路器误计为失败的问题。Codex 对话停在启动提示上、正常退出三次后,不再把整个工作区隔离到 `session.crashed.json`;已保存的对话目标保留至下次启动。 +- 修复:部分 AI 身份无法取得时关闭流程卡住的问题。用户可以保存已知状态并退出;保存失败或编辑器草稿随后发生变化时仍保持应用打开。 +- 修复:AI 失败和取消事件被显示为成功完成的问题,Claude 失败事件及 Pi 重试后的结果统一进入通知结果处理。 +- 修复:通知替换后残留旧结果或旧到期时间的问题。新的窗格结果遵循选定时长,旧计时器不能关闭替换后的通知。 +- 修复:受支持 AI 确认动作:发送前检查请求仍然有效,并完成必要的按下、释放按键序列;Windows 通知激活时恢复对应应用窗口。 +- 修复:WSL Hook 安装与事件传递,使用发行版自身的用户配置;命令补全同步包含发行版和用户选项。 +- 修复:继承的 Ctrl+C 忽略状态导致 CMD 子命令无法中断的问题,同时保留选区复制。 +- 修复:原生 CMD 命令活动检测、多行输入以及等待折行边界的回显文本跟踪。 +- 修复:应用的 256 色块被终端提示符颜色覆盖的问题,保留明确配置的自定义颜色覆盖。 +- 修复:已保存 SSH 主机在自动认证时忽略可用系统 SSH 代理的问题。 +- 修复:终端工作目录复制,改为复制 Shell 上报的路径,包括访客环境路径;普通选区复制保留原始文字。 +- 修复:终端和回答阅读页打开受支持的本地 Markdown 链接、文件 URI 及相对路径的行为。含空格、中文或括号的路径按原窗格目录解析,路径缺失时提供可见反馈。对应 [#203](https://github.com/Kuddev/pebrel/issues/203)。 +- 修复:文件编辑器选区与查找命中融入浅色、深色背景的问题;普通文本和代码编辑器可透出已配置的工作区背景图,光标所在行跟随主题。 +- 修复:Markdown 目录长标题压住相邻行的问题,完整标题仍可通过悬停提示和复制取得。对应 [#184](https://github.com/Kuddev/pebrel/issues/184)。 +- 修复:Markdown 拖选被中断后,未按住鼠标移动仍继续扩大选区的问题。 +- 修复:代码语言菜单在窄窗口中越界的问题,并保持搜索框和键盘操作可用。 +- 修复:SSH 主机图标因字体可见轮廓与排版宽度不同而偏离中心的问题。 +- 修复:移除拖动标签时重复出现的标题预览。 ### 改进 -- Markdown 默认以只读方式渲染,保留文字选择、复制、代码复制及公式操作;编辑需要显式切换到源码模式。 -- 阅读模式仍可选择代码块语言,只改变语法高亮,不改写 Markdown;滚动离开再返回后保留选择,复制得到的代码内容不变。 -- 从源码模式返回阅读页时保留草稿,同时阻止只读模式下的撤销、重做快捷键修改文档。 -- Markdown 使用虚拟列表、可见代码块和图片资源管理,并释放非活动文档的布局缓存,减少重复工作;不把单机结果写成固定的内存或速度承诺。 -- Git/SVN 抽屉复用仓库快照,仅渲染可见条目,减少大型仓库中的重复工作。 -- Agents 页面整体居中,九种 Agent 图标使用放大的 SVG 资源;CLI 是否存在与 Hook 是否安装分别展示,长路径可通过悬停提示查看。 -- 改进设置搜索、SSH 连接卡片和筛选、文件面板控件对齐,以及命令分组的导航、排序和新增操作。 -- 安装或移除受管理的 Agent Hook 时,保留其他集成及用户已有配置。 -- 暂时隐藏设置侧栏和搜索中的备份入口,本版不开放云备份控件。 +- 改进:Markdown 默认以只读方式渲染,保留文字选择、复制、代码复制及公式操作;编辑需要显式切换到源码模式。 +- 改进:阅读模式仍可选择代码块语言,只改变语法高亮,不改写 Markdown;滚动离开再返回后保留选择,复制得到的代码内容不变。 +- 改进:从源码模式返回阅读页时保留草稿,同时阻止只读模式下的撤销、重做快捷键修改文档。 +- 改进:Markdown 使用虚拟列表、可见代码块和图片资源管理,并释放非活动文档的布局缓存,减少重复工作;不把单机结果写成固定的内存或速度承诺。 +- 改进:Git/SVN 抽屉复用仓库快照,仅渲染可见条目,减少大型仓库中的重复工作。 +- 改进:Agents 页面整体居中,九种 Agent 图标使用放大的 SVG 资源;CLI 是否存在与 Hook 是否安装分别展示,长路径可通过悬停提示查看。 +- 改进:设置搜索、SSH 连接卡片和筛选、文件面板控件对齐,以及命令分组的导航、排序和新增操作。 +- 改进:安装或移除受管理的 Agent Hook 时,保留其他集成及用户已有配置。 +- 改进:暂时隐藏设置侧栏和搜索中的备份入口,本版不开放云备份控件。 Windows x64 提供安装器和 ZIP 便携包,Windows ARM64 提供原生便携 ZIP;安装器和应用内自动安装仍仅支持 x64。Linux x64 包及 macOS Apple Silicon/Intel DMG 继续标记为 Preview。macOS 最低版本为 14。分子结构渲染仍处于禁用状态。 diff --git a/scripts/conformance/windows_arm64_module_path.ps1 b/scripts/conformance/windows_arm64_module_path.ps1 index ec205e8..d52238e 100644 --- a/scripts/conformance/windows_arm64_module_path.ps1 +++ b/scripts/conformance/windows_arm64_module_path.ps1 @@ -5,7 +5,8 @@ # rebuilds child environments from the registry (the stock console host rule), so # the injected entries never reach the default tab; putting them into the # user-level registry value keeps the rebuild faithful and PS 5.1 alive. -# Evidence: .github/workflows/windows-arm64-diagnostics.yml bisect, 2026-09-22. +# Evidence: environment-block replay and PSModulePath bisect on the ARM64 +# runner, 2026-09-22. [CmdletBinding()] param() From 97f829844dd35b7bcaa5ff9d3c746c0c3df4a3d6 Mon Sep 17 00:00:00 2001 From: kuddev Date: Tue, 22 Sep 2026 21:46:08 +0800 Subject: [PATCH 19/20] ci: stop repeating the native test matrix inside the stable release run The release run re-invoked the Full native tests workflow and waited for it before aggregating packages. That workflow already runs for every pull request, merge group and main push, so the repeat added no coverage while its macOS Intel job queued behind the Intel package build and became the critical path (60 minutes end to end, packages done after 28). Aggregation now depends on the packaging jobs only; each package still runs its own runtime conformance and the aggregate still validates every report. --- .github/workflows/release.yml | 13 ++----------- scripts/tests/test_stable_release.py | 16 ++++++++++------ 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 25ee9e3..b3a9f44 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,7 @@ on: type: string default: "" validated_run: - description: Existing stable run whose Windows native tests passed for source_commit + description: Existing stable or Full native tests run whose Windows tests passed for source_commit type: string default: "" macos_signing: @@ -119,15 +119,6 @@ jobs: bash -n scripts/package-linux.sh scripts/package-macos.sh python3 -m py_compile scripts/stable_release.py scripts/preview_release.py - native-tests: - name: Native tests - permissions: - contents: read - actions: read - if: ${{ !inputs.windows_only }} - needs: prepare - uses: ./.github/workflows/linux-lua.yml - linux: name: Linux x86_64 stable package if: ${{ !inputs.windows_only }} @@ -625,7 +616,7 @@ jobs: aggregate: name: Verify stable assets and upload complete set if: ${{ !inputs.windows_only }} - needs: [prepare, native-tests, linux, macos, windows, windows-arm64] + needs: [prepare, linux, macos, windows, windows-arm64] runs-on: ubuntu-22.04 env: PACKAGE_VERSION: ${{ needs.prepare.outputs.version }} diff --git a/scripts/tests/test_stable_release.py b/scripts/tests/test_stable_release.py index a98796e..dd23386 100644 --- a/scripts/tests/test_stable_release.py +++ b/scripts/tests/test_stable_release.py @@ -68,22 +68,26 @@ def notes(checksum_placeholder: bool = True) -> str: class StableReleaseTests(unittest.TestCase): - def test_stable_workflow_requires_full_native_tests_before_aggregation(self) -> None: + def test_stable_workflow_packages_without_repeating_native_tests(self) -> None: + # The Full native tests workflow already covers every PR, merge group + # and main push; the release run only packages and verifies the runtime + # conformance evidence of each package, so its wall time is bounded by + # the slowest build rather than by test scheduling. root = Path(__file__).resolve().parents[2] workflow = (root / ".github/workflows/release.yml").read_text(encoding="utf-8") - native = workflow.split(" native-tests:\n", 1)[1].split("\n linux:\n", 1)[0] - self.assertIn("uses: ./.github/workflows/linux-lua.yml", native) + self.assertNotIn(" native-tests:\n", workflow) + self.assertNotIn("uses: ./.github/workflows/linux-lua.yml", workflow) shared = (root / ".github/workflows/linux-lua.yml").read_text(encoding="utf-8") for platform in ("ubuntu-24.04", "windows-2022", "windows-11-arm", "macos-26", "macos-26-intel"): self.assertIn(platform, shared) - self.assertIn("workflow_call:", shared) + for trigger in ("pull_request:", "merge_group:", "branches: [main]"): + self.assertIn(trigger, shared) self.assertIn("run: python scripts/ci_native_tests.py", shared) self.assertIn("cargo check --locked --workspace --release", shared) self.assertIn("tools/i18n-contract/Cargo.toml", shared) self.assertNotIn("continue-on-error", shared) - self.assertNotIn("continue-on-error", native) aggregate = workflow.split("\n aggregate:\n", 1)[1].split("\n publish:\n", 1)[0] - self.assertIn("needs: [prepare, native-tests, linux, macos, windows, windows-arm64]", aggregate) + self.assertIn("needs: [prepare, linux, macos, windows, windows-arm64]", aggregate) self.assertIn("windows_arm64=True", aggregate) arm = workflow.split("\n windows-arm64:\n", 1)[1].split("\n aggregate:\n", 1)[0] for required in ("runs-on: windows-11-arm", "host: aarch64-pc-windows-msvc", From 17fea2d42293c7e970724617263773927cc44dd8 Mon Sep 17 00:00:00 2001 From: kuddev Date: Tue, 22 Sep 2026 22:38:14 +0800 Subject: [PATCH 20/20] docs(release): record the final 1.9.0 asset checksums --- CHANGELOG.md | 16 ++++++++-------- docs/release-notes/v1.9.0.md | 16 ++++++++-------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3625135..7fd25da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -119,14 +119,14 @@ Windows x64 提供安装器和 ZIP 便携包,Windows ARM64 提供原生便携 **SHA256** -- `Pebrel-v1.9.0-linux-x64-preview.AppImage`: `PENDING FINAL BUILD` -- `Pebrel-v1.9.0-linux-x64-preview.deb`: `PENDING FINAL BUILD` -- `Pebrel-v1.9.0-linux-x64-preview.tar.gz`: `PENDING FINAL BUILD` -- `Pebrel-v1.9.0-macos-arm64-preview.dmg`: `PENDING FINAL BUILD` -- `Pebrel-v1.9.0-macos-x64-preview.dmg`: `PENDING FINAL BUILD` -- `Pebrel-v1.9.0-windows-x64.zip`: `PENDING FINAL BUILD` -- `Pebrel-v1.9.0-windows-x64-setup.exe`: `PENDING FINAL BUILD` -- `Pebrel-v1.9.0-windows-arm64.zip`: `PENDING FINAL BUILD` +- `Pebrel-v1.9.0-linux-x64-preview.AppImage`: `4da5ea590a82ab4f89dca14df1399e7c2cd27fa4fe308da6f0e95b8aa9ae7d22` +- `Pebrel-v1.9.0-linux-x64-preview.deb`: `bb6fff964942b750a794f2ea148e851719430dfffd9bb086a13e2f1e67d87722` +- `Pebrel-v1.9.0-linux-x64-preview.tar.gz`: `6526ed5f2f883bb5ca9600d2685c929056fbae9f2450259f34d65093a18deda6` +- `Pebrel-v1.9.0-macos-arm64-preview.dmg`: `daf2c6c381fbdf84c33c132e9c6c247e9f9dc6e86292cc5be855e635e6cfa6f3` +- `Pebrel-v1.9.0-macos-x64-preview.dmg`: `9f3f1465be9e1f3489be09ccf336d5bdc0a6ae40185062f677a6e857d47e1fe0` +- `Pebrel-v1.9.0-windows-x64.zip`: `1c219946450c7d93993394fd6d40c6e234d77547c9b4a98106c0d33ada9c27f9` +- `Pebrel-v1.9.0-windows-x64-setup.exe`: `52cefcdae9cd98035bcdd45944c020fc9acf9c2c497147ba337ed15069a07fe6` +- `Pebrel-v1.9.0-windows-arm64.zip`: `e2d062089afb19ddb9e3d078cc7f460a1d67cb36d95400cebab837f9fabda092` ## 1.8.2 - 2026-09-17 diff --git a/docs/release-notes/v1.9.0.md b/docs/release-notes/v1.9.0.md index 175094f..bd0fdd9 100644 --- a/docs/release-notes/v1.9.0.md +++ b/docs/release-notes/v1.9.0.md @@ -113,11 +113,11 @@ Windows x64 提供安装器和 ZIP 便携包,Windows ARM64 提供原生便携 **SHA256** -- `Pebrel-v1.9.0-linux-x64-preview.AppImage`: `PENDING FINAL BUILD` -- `Pebrel-v1.9.0-linux-x64-preview.deb`: `PENDING FINAL BUILD` -- `Pebrel-v1.9.0-linux-x64-preview.tar.gz`: `PENDING FINAL BUILD` -- `Pebrel-v1.9.0-macos-arm64-preview.dmg`: `PENDING FINAL BUILD` -- `Pebrel-v1.9.0-macos-x64-preview.dmg`: `PENDING FINAL BUILD` -- `Pebrel-v1.9.0-windows-x64.zip`: `PENDING FINAL BUILD` -- `Pebrel-v1.9.0-windows-x64-setup.exe`: `PENDING FINAL BUILD` -- `Pebrel-v1.9.0-windows-arm64.zip`: `PENDING FINAL BUILD` +- `Pebrel-v1.9.0-linux-x64-preview.AppImage`: `4da5ea590a82ab4f89dca14df1399e7c2cd27fa4fe308da6f0e95b8aa9ae7d22` +- `Pebrel-v1.9.0-linux-x64-preview.deb`: `bb6fff964942b750a794f2ea148e851719430dfffd9bb086a13e2f1e67d87722` +- `Pebrel-v1.9.0-linux-x64-preview.tar.gz`: `6526ed5f2f883bb5ca9600d2685c929056fbae9f2450259f34d65093a18deda6` +- `Pebrel-v1.9.0-macos-arm64-preview.dmg`: `daf2c6c381fbdf84c33c132e9c6c247e9f9dc6e86292cc5be855e635e6cfa6f3` +- `Pebrel-v1.9.0-macos-x64-preview.dmg`: `9f3f1465be9e1f3489be09ccf336d5bdc0a6ae40185062f677a6e857d47e1fe0` +- `Pebrel-v1.9.0-windows-x64.zip`: `1c219946450c7d93993394fd6d40c6e234d77547c9b4a98106c0d33ada9c27f9` +- `Pebrel-v1.9.0-windows-x64-setup.exe`: `52cefcdae9cd98035bcdd45944c020fc9acf9c2c497147ba337ed15069a07fe6` +- `Pebrel-v1.9.0-windows-arm64.zip`: `e2d062089afb19ddb9e3d078cc7f460a1d67cb36d95400cebab837f9fabda092`