From 932ce2a903eb6b90bec27f29662fdc457ec9d96c Mon Sep 17 00:00:00 2001 From: zher52 Date: Thu, 15 Oct 2026 15:56:27 +0800 Subject: [PATCH 1/6] feat(gpui): a bindable clear-screen action for the terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GPUI shell had no clear-screen action at all: the macOS config table's ⌘K ClearHistory default executes only in the legacy shell, so after unbinding the Shell picker row ⌘K stayed inert and no settings row offered clear screen — the known limitation recorded in the 2026-09-21 macOS command-key aliases note. Map config::Action::ClearHistory to a new ClearTerminal workspace action and expose it as a Terminal-group row on the settings keymap page. No default shortcut changes on any platform: ⌘K stays on the Shell picker, and the feature is opt-in through a user keybind= row that applies without restart. After an instant app-side wipe of the viewport and scrollback, the action branches on the pane's foreground state: at a prompt it writes ^L to the pane's PTY so the shell repaints its prompt on the fresh screen; while a command runs it moves the terminal cursor home app-side so sequential output continues from the top; on the alternate screen it does nothing. The row label uses a typed i18n message (zh/en catalogs, English fallback elsewhere). Adds adapter, dispatch-probe and label regressions, and the decision note architecture/notes/nebula_app/gpui_shell/2026-10-14-terminal-clear-action.md (recording why pre-existing files need one re-record after upgrading, and why an app-side cursor-home and an unconditional ^L were rejected). --- .../2026-10-14-terminal-clear-action.md | 147 ++++++++++++++++++ nebula_app/i18n/en-US.json | 1 + nebula_app/i18n/zh-CN.json | 1 + nebula_app/src/display/keymap.rs | 28 +++- nebula_app/src/gpui_shell/terminal/view.rs | 28 +++- .../src/gpui_shell/workspace/key_actions.rs | 9 ++ .../gpui_shell/workspace/keyboard_bindings.rs | 14 ++ .../workspace/keyboard_bindings/tests.rs | 100 ++++++++++++ nebula_app/src/gpui_shell/workspace/render.rs | 3 + 9 files changed, 325 insertions(+), 6 deletions(-) create mode 100644 architecture/notes/nebula_app/gpui_shell/2026-10-14-terminal-clear-action.md diff --git a/architecture/notes/nebula_app/gpui_shell/2026-10-14-terminal-clear-action.md b/architecture/notes/nebula_app/gpui_shell/2026-10-14-terminal-clear-action.md new file mode 100644 index 000000000..75f70809b --- /dev/null +++ b/architecture/notes/nebula_app/gpui_shell/2026-10-14-terminal-clear-action.md @@ -0,0 +1,147 @@ +# Bindable terminal clear action (GPUI shell) + +## Status + +Implemented; resolves the clear-screen limitation recorded in +[2026-09-21-macos-command-key-aliases.md](2026-09-21-macos-command-key-aliases.md) +under Consequences and Revisit when. + +## Context + +The GPUI product shell had no clear-screen action at all. `config::Action::ClearHistory` +and the `Esc("\x0c")` sibling existed only in the legacy shell's action table +(`nebula_app/src/input/action.rs`), and the product build replaces that module with +`product_input.rs`, so the macOS config-table defaults `⌘K → Esc("\x0c")` plus +`⌘K → ClearHistory` (`nebula_app/src/config/bindings/defaults.rs`, macOS platform +section) were inert data. Runtime ⌘K belongs to the Shell picker via +`display::keymap::MACOS_COMMAND_ALIASES`. After a user cleared the Shell picker row, +`clear_action` wrote `keybind=cmd+k:ReceiveChar`, which the shell registers as +`NoAction`; ⌘K became inert. Even a hand-written `keybind=cmd+k:ClearHistory` was +dropped because `workspace_binding_in_context` had no `ClearHistory` arm, and the +settings keymap page offered no clear-screen row. Users coming from the +established macOS ⌘K clear convention had no path to that behavior. + +## Evidence + +- `nebula_app/src/main.rs` selects `product_input.rs` for `input` when the legacy + shell is disabled; `input::Processor` / `process_key_bindings` are legacy-only. +- `nebula_app/src/config/bindings/defaults.rs` macOS platform table binds ⌘K twice + (`Esc("\x0c")`, `ClearHistory`); `nebula_terminal/src/term/clear.rs` shows + `ClearMode::Saved` clears only the scrollback while `ClearMode::All` clears the + viewport. +- `nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs`: the adapter's match + fell through to `_ => None` for `ClearHistory` before this change; user rows are + registered at workspace and terminal depth by `update_keybinds`. +- The 2026-09-21 alias note documents "⌘K after unbinding does not clear the + screen … the key is released but inert" and defers the decision. + +## Decision + +- `nebula_workspace::ClearTerminal` is a new GPUI action. `Action::ClearHistory` + maps to it in `workspace_binding_in_context` with the terminal key context, + the same shape as Copy/Paste, so text inputs never lose the key. +- **No default binding is registered anywhere.** ⌘K stays on the Shell picker on + macOS and nothing changes on Windows/Linux. The action is reachable only through + a user `keybind=` row, now writable from the settings page: `EDITABLE_ACTIONS` + gains a terminal-group row backed by `Action::ClearHistory` (a unit variant that + already round-trips through `parse_action` / `action_storage_name`). +- Semantics branch on the pane's foreground state after an instant app-side wipe + of the viewport and scrollback (`ClearMode::All` + `Saved`, zero-latency + feedback): + - Shell at a prompt (OSC 133 `nebula_prompt_active`): write `\x0c` to that + pane's PTY so the shell moves the cursor home and repaints the prompt on the + fresh screen, keeping its line state in sync. + - Command running: the shell cannot act on `^L` (it would stay queued and + clear the screen a second time when the command exits, wiping the output + produced in between). Instead the view moves the terminal cursor home + app-side (`Handler::goto(0, 0)`); sequential output continues from the top + and the next prompt lands after the last output line. + - Alternate screen (vim, less, other full-screen programs): the action does + nothing — `^L` is a redraw there, not a clear. + Clearing the grid alone leaves the cursor on its original row and the prompt + stranded; manual acceptance rejected that. The `^L` byte is a direct PTY write + on the focused pane, not a user-input event, so broadcast fan-out stays out of + scope. Without shell integration the prompt probe reads false, so clears take + the cursor-home branch; the wipe still works and the next Enter restores a + prompt. +- The row label uses the typed message `common.clear_screen` (Simplified Chinese + and English catalogs; other languages fall back per the catalog contract). + +## Rejected alternatives + +- A pure app-side grid clear with no PTY write (the first implementation): + deterministic under any foreground program, but the cursor stays on its + original row and the shell never repaints the prompt — after the wipe the + screen is blank with a stray cursor, and only the next Enter restores a + visible prompt. Manual acceptance rejected this as broken. +- An app-side cursor-home instead of `^L` at the prompt: desynchronizes the + shell's line state from the screen and breaks cursor-addressed redraws + (wrapped input lines, history search) without any cooperation from the shell. +- An unconditional `^L` (the exact legacy ⌘K pair): correct at the prompt, but + while a command runs the byte stays queued in the tty input queue and the + shell consumes it only after the command exits — clearing the screen a second + time and wiping the output the command produced in between. The prompt-state + probe avoids the deferred wipe. +- Scrollback-only (`ClearMode::Saved` alone, matching the legacy `ClearHistory` + executor): leaves the screen populated; users asking for clear screen expect a + blank viewport plus a dropped scrollback. +- Reassigning ⌘K in `MACOS_COMMAND_ALIASES` to `ClearHistory` (the common macOS + terminal clear convention): changes shipped default behavior for every macOS + user. This change + deliberately preserves existing defaults; users who want it bind ⌘K themselves + through the new row. +- Registering the config table's ⌘K `ClearHistory` default as a GPUI workspace + default: collides with the Shell picker alias in the same keymap source tier and + makes dispatch ambiguous. + +## Consequences + +- On a fresh macOS install the Clear screen row displays `Command+K`, because + `effective_combo` reads the config table and `mods_prefix` renders `super` + natively — while runtime ⌘K actually opens the Shell picker. This is the same + config-table display convention recorded in the 2026-09-21 note ("the keymap + page still shows Ctrl keys for actions whose macOS binding set includes ⌘"), + not a new runtime claim. Capturing any key on the row writes a user row that + wins immediately. +- Resetting the Clear screen row also removes `cmd+k:ReceiveChar` lines + (`reset_action`'s vocabulary includes config-table defaults), which hands ⌘K + back to the Shell picker. Clearing the row writes `keybind=cmd+k:ReceiveChar`, + which shadows the picker's ⌘K — ordinary combo-level shadowing, identical to + every other row. +- Windows/Linux gain an unbound row and no behavior change; existing default + keybindings are untouched on every platform. +- Files written by earlier builds hold no `ClearHistory` line — the old shell + could not express one — only the `ReceiveChar` leftovers of unbinding the + Shell picker row. Those lines keep their "release the key" semantics after + upgrading, so a pre-update clear-screen attempt stays inert until the user + records the row once (verified against a real upgraded file: the re-record + replaced `cmd+k:ReceiveChar` with `win+k:ReceiveChar` plus the new + `win+k:ClearHistory`, where the later line wins). Auto-rewriting + `ReceiveChar` lines into `ClearHistory` would overwrite an explicitly chosen + "release the key" decision and is rejected. +- At the prompt the `^L` byte reaches whatever shell owns the normal-screen PTY, + exactly as the legacy ⌘K pair behaved. In the running-command branch no PTY + write happens; alternate-screen sessions are left untouched entirely. +- The legacy shell already executes `Action::ClearHistory` and is unchanged. + +## Validation + +`cargo fmt --all -- --check`, `cargo check -p nebula --bin pebrel --features +gpui-shell --tests --locked`, the `display::keymap` and +`gpui_shell::workspace::keyboard_bindings` test groups (including the new +adapter, dispatch-probe and label regressions), the settings-pane suite, the +i18n contract suites, `cargo test -p nebula --test file_line_budget` and +`python3 scripts/check_architecture.py --base ` pass. The dispatch +regression fails without the adapter arm; the label regression fails without the +catalog entries. + +## Supersedes + +None. Extends [2026-09-21-macos-command-key-aliases.md](2026-09-21-macos-command-key-aliases.md); +its alias-table authority and unbind vocabulary are reused unchanged. + +## Revisit when + +The keymap page renders platform-native or runtime-truth keycaps (removing the +Command+K display quirk), or the product decides clear screen deserves a default +key of its own. diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index 19d7bba1b..37c3ec194 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -349,6 +349,7 @@ "edit": "Edit", "rename": "Rename", "rename_tab": "Rename tab", + "clear_screen": "Clear screen", "copy": "Copy", "paste": "Paste", "cut": "Cut", diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index 368ce78d4..839448b03 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -349,6 +349,7 @@ "edit": "编辑", "rename": "重命名", "rename_tab": "重命名标签页", + "clear_screen": "清屏", "copy": "复制", "paste": "粘贴", "cut": "剪切", diff --git a/nebula_app/src/display/keymap.rs b/nebula_app/src/display/keymap.rs index 02542f040..7ebd34b86 100644 --- a/nebula_app/src/display/keymap.rs +++ b/nebula_app/src/display/keymap.rs @@ -69,6 +69,9 @@ pub(crate) const EDITABLE_ACTIONS: &[(Action, &str, &str)] = &[ (Action::IncreaseFontSize, "增大字号", "Font size up"), (Action::DecreaseFontSize, "减小字号", "Font size down"), (Action::ResetFontSize, "重置字号", "Reset font size"), + // GPUI 壳的清屏没有静态默认键(⌘K 归 Shell 选择器);这一行让用户 + // 能把任意键(含 ⌘K)绑到清屏,落到 ClearTerminal 动作。 + (Action::ClearHistory, "清屏", "Clear screen"), ]; /// 设置页分组(无框裁定 2026-08-09:只用段标题+间距分组,不画容器)。 @@ -78,7 +81,7 @@ pub(crate) const GROUPS: &[(&str, &str, usize)] = &[ ("标签页", "Tabs", 5), ("窗格", "Panes", 7), ("侧栏面板", "Side panels", 2), - ("终端", "Terminal", 9), + ("终端", "Terminal", 10), ]; /// 只读展示行(无法在图形页编辑,TOML/settings 行仍可覆盖其中的表驱动键)。 @@ -180,10 +183,10 @@ pub(crate) fn action_label( row: &(Action, &'static str, &'static str), language: super::UiLanguage, ) -> &'static str { - if row.0 == Action::RenameTab { - language.text(crate::i18n::Message::CommonRenameTab) - } else { - language.pick(row.1, row.2) + match row.0 { + Action::RenameTab => language.text(crate::i18n::Message::CommonRenameTab), + Action::ClearHistory => language.text(crate::i18n::Message::CommonClearScreen), + _ => language.pick(row.1, row.2), } } @@ -1010,6 +1013,21 @@ mod tests { assert_eq!(action_label(row, super::super::UiLanguage::ZhCn), "重命名标签页"); } + #[test] + fn clear_screen_is_editable_and_localized_through_the_typed_message() { + use super::*; + let row = + EDITABLE_ACTIONS.iter().find(|(action, ..)| *action == Action::ClearHistory).unwrap(); + for language in super::super::UiLanguage::ALL { + assert_eq!( + action_label(row, *language), + language.text(crate::i18n::Message::CommonClearScreen) + ); + assert!(!action_label(row, *language).is_empty()); + } + assert_eq!(action_label(row, super::super::UiLanguage::ZhCn), "清屏"); + } + #[test] fn clearing_ctrl_k_survives_persistence_and_restore_is_explicit() { use super::*; diff --git a/nebula_app/src/gpui_shell/terminal/view.rs b/nebula_app/src/gpui_shell/terminal/view.rs index d608cd92b..ddc4b8fa6 100644 --- a/nebula_app/src/gpui_shell/terminal/view.rs +++ b/nebula_app/src/gpui_shell/terminal/view.rs @@ -49,7 +49,7 @@ use nebula_terminal::index::{Column, Line, Point as TermPoint, Side}; use nebula_terminal::render::{CellMetrics, TerminalViewport, ViewportTracker}; use nebula_terminal::selection::{Selection, SelectionType}; use nebula_terminal::term::TermMode; -use nebula_terminal::vte::ansi::CursorStyle; +use nebula_terminal::vte::ansi::{ClearMode, CursorStyle, Handler as _}; use std::sync::Arc; @@ -1067,6 +1067,32 @@ impl TerminalView { true } + /// 清屏(设置页「清屏」动作的落点)。应用侧立刻清掉可见屏与回滚(零 + /// 延迟反馈),随后按前台状态分流: + /// - 提示符状态(shell 在等输入,OSC 133 跟踪):发 ^L 让 shell 光标归 + /// 顶并重绘提示符,行状态与屏幕保持同步; + /// - 命令运行中:shell 读不到 ^L(只会排队到命令结束后再清一次屏,冲 + /// 掉运行期输出),应用侧直接把光标归顶,顺序输出从顶部继续; + /// - 备用屏(vim/less 等全屏程序):^L 不是清屏,整键不动。 + pub fn clear_screen(&mut self, cx: &mut Context) { + let Some(session) = &self.session else { return }; + { + let mut term = session.term.lock(); + if term.mode().contains(TermMode::ALT_SCREEN) { + return; + } + term.clear_screen(ClearMode::All); + term.clear_screen(ClearMode::Saved); + if term.nebula_prompt_active() { + drop(term); + self.write_bytes(b"\x0c".to_vec()); + } else { + term.goto(0, 0); + } + } + cx.notify(); + } + fn paste_now(&mut self, text: &str, cx: &mut Context) { self.paste_now_impl(text, true, cx); } diff --git a/nebula_app/src/gpui_shell/workspace/key_actions.rs b/nebula_app/src/gpui_shell/workspace/key_actions.rs index 25003931d..c1ad433de 100644 --- a/nebula_app/src/gpui_shell/workspace/key_actions.rs +++ b/nebula_app/src/gpui_shell/workspace/key_actions.rs @@ -123,4 +123,13 @@ impl NebulaWorkspace { view.update(cx, |view, cx| view.paste(window, cx)); } } + + /// 清空焦点终端。清屏由焦点 pane 的视图执行(提示符状态随附的 ^L + /// 也只写该 pane 的 PTY);它不是用户输入事件,与复制/粘贴一样不走 + /// 广播扇出。 + pub(super) fn clear_focused_terminal(&mut self, cx: &mut Context) { + if let Some(view) = self.tabs.get(self.active).and_then(WorkspaceTab::focused_view) { + view.update(cx, |view, cx| view.clear_screen(cx)); + } + } } diff --git a/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs b/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs index 457827d69..966a0c08d 100644 --- a/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs +++ b/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs @@ -197,6 +197,13 @@ pub(super) struct SelectTab { #[action(namespace = nebula_workspace, no_json)] pub(super) struct QuitApp; +/// 清屏(`Action::ClearHistory` 的 GPUI 落点),与 SelectTab/QuitApp 同属 +/// 适配层动作。没有静态默认绑定:⌘K 已归 Shell 选择器,清屏只能经设置页 +/// keybind= 行注册。 +#[derive(Clone, Debug, PartialEq, gpui::Action)] +#[action(namespace = nebula_workspace, no_json)] +pub(super) struct ClearTerminal; + impl SelectTab { fn from_config(action: &crate::config::Action) -> Option { use crate::config::Action; @@ -272,6 +279,13 @@ fn workspace_binding_in_context( PasteClipboard, scope.or(Some(crate::gpui_shell::terminal::KEY_CONTEXT)), )), + // 清屏只有用户 keybind= 行会走到这里(无静态默认,⌘K 归 Shell + // 选择器);作用域与 Copy/Paste 同型,输入框聚焦时不抢键。 + Action::ClearHistory => Some(KeyBinding::new( + &combo, + ClearTerminal, + scope.or(Some(crate::gpui_shell::terminal::KEY_CONTEXT)), + )), Action::ToggleFullscreen => Some(KeyBinding::new(&combo, ToggleFullscreen, scope)), Action::OpenQuickJump => Some(KeyBinding::new(&combo, OpenQuickJump, scope)), // macOS 的退出键走与托盘退出同一条路径:先落盘会话与草稿,再停 PTY。 diff --git a/nebula_app/src/gpui_shell/workspace/keyboard_bindings/tests.rs b/nebula_app/src/gpui_shell/workspace/keyboard_bindings/tests.rs index 1a68f476c..0c84d9fcf 100644 --- a/nebula_app/src/gpui_shell/workspace/keyboard_bindings/tests.rs +++ b/nebula_app/src/gpui_shell/workspace/keyboard_bindings/tests.rs @@ -108,6 +108,57 @@ mod macos_command_keys { } } +/// 清屏动作必须能被 keybind= 行表达:此前 `Action::ClearHistory` 落入 +/// `_ => None` 被静默丢弃,用户解绑 ⌘K 后该键成为惰性键(#238 note 的 +/// 已知遗留)。这条守住适配分支不再消失。 +#[test] +fn clear_history_maps_to_the_clear_terminal_action() { + use crate::config::Action; + use gpui::{KeyContext, Keymap, Keystroke}; + let bound = workspace_binding_in_context("cmd+k", &Action::ClearHistory, None) + .expect("ClearHistory 必须有对应的 gpui 绑定"); + assert!(bound.action().as_any().is::()); + + // 运行时形状:update_keybinds 在两个作用域各注册一次,这里照抄。 + let user = vec![ + bound, + workspace_binding_in_context( + "cmd+k", + &Action::ClearHistory, + Some(crate::gpui_shell::terminal::KEY_CONTEXT), + ) + .unwrap(), + ]; + let mut statics = default_workspace_bindings(); + if cfg!(target_os = "macos") { + // 对照组:macOS 的 ⌘K 静态默认键(Shell 选择器)必须存在, + // 否则「用户清屏压过默认键」的断言空跑。 + statics.push(custom_workspace_binding("cmd+k", &Action::ToggleShellPicker).unwrap()); + } + let keymap = Keymap::new(with_user_keybindings(&Keymap::new(statics), user)); + + let contexts = [ + KeyContext::parse("Root").unwrap(), + KeyContext::parse(crate::gpui_shell::terminal::KEY_CONTEXT).unwrap(), + ]; + let (bindings, pending) = + keymap.bindings_for_input(&[Keystroke::parse("cmd-k").unwrap()], &contexts); + assert!(!pending); + assert!( + bindings[0].action().as_any().is::(), + "用户绑定的清屏必须压过静态默认键" + ); + // Shell 选择器自身的默认键不受影响:Ctrl+K 在所有平台都是它的 + // 工作区默认键,用户给 ⌘K 绑清屏不改变这一点。 + let (bindings, pending) = + keymap.bindings_for_input(&[Keystroke::parse("ctrl-k").unwrap()], &contexts); + assert!(!pending); + assert!( + bindings[0].action().as_any().is::(), + "清屏绑定不得挤占 Shell 选择器的默认键" + ); +} + #[test] fn cleared_shortcut_reaches_terminal_and_can_be_restored_without_restart() { use crate::config::Action; @@ -936,6 +987,7 @@ mod dispatch { focus: FocusHandle, selected: Option, renamed: usize, + cleared: usize, mode: TermMode, input: Vec, encoded: Vec, @@ -949,6 +1001,7 @@ mod dispatch { this.selected = action.index_for(10); })) .on_action(cx.listener(|this, _: &RenameActiveTab, _, _| this.renamed += 1)) + .on_action(cx.listener(|this, _: &ClearTerminal, _, _| this.cleared += 1)) .child( div() .size_full() @@ -982,6 +1035,7 @@ mod dispatch { focus: cx.focus_handle(), selected: None, renamed: 0, + cleared: 0, mode: TermMode::empty(), input: Vec::new(), encoded: Vec::new(), @@ -1071,6 +1125,7 @@ mod dispatch { focus: cx.focus_handle(), selected: None, renamed: 0, + cleared: 0, mode: TermMode::empty(), input: Vec::new(), encoded: Vec::new(), @@ -1109,4 +1164,49 @@ mod dispatch { }); } } + + /// `keybind=cmd+k:ClearHistory`(设置页「清屏」行录制 ⌘K 的落盘形状) + /// 必须端到端派发 ClearTerminal,且按键不进入终端输入流。 + #[gpui::test] + fn clear_screen_shortcut_dispatches_and_stays_out_of_the_terminal_stream( + cx: &mut TestAppContext, + ) { + cx.update(|cx| { + gpui_component::init(cx); + init(cx); + // 运行时形状:update_keybinds 对每条 keybind= 行在两个作用域 + // 各注册一次。 + for scope in [None, Some(crate::gpui_shell::terminal::KEY_CONTEXT)] { + cx.bind_keys([workspace_binding_in_context( + "cmd+k", + &crate::config::Action::ClearHistory, + scope, + ) + .unwrap()]); + } + }); + let mut probe_out = None; + let (_, cx) = cx.add_window_view(|window, cx| { + let probe = cx.new(|cx| TerminalKeyProbe { + focus: cx.focus_handle(), + selected: None, + renamed: 0, + cleared: 0, + mode: TermMode::empty(), + input: Vec::new(), + encoded: Vec::new(), + }); + let focus = probe.read(cx).focus.clone(); + focus.focus(window, cx); + probe_out = Some(probe.clone()); + Root::new(probe, window, cx) + }); + let probe = probe_out.unwrap(); + press("cmd-k", cx); + probe.read_with(cx, |p, _| { + assert_eq!(p.cleared, 1, "清屏绑定必须派发 ClearTerminal"); + assert!(p.input.is_empty(), "清屏快捷键不得进入 PTY 输入流"); + assert!(p.encoded.is_empty()); + }); + } } diff --git a/nebula_app/src/gpui_shell/workspace/render.rs b/nebula_app/src/gpui_shell/workspace/render.rs index a5cf76c79..3d61cd6ca 100644 --- a/nebula_app/src/gpui_shell/workspace/render.rs +++ b/nebula_app/src/gpui_shell/workspace/render.rs @@ -244,6 +244,9 @@ impl Render for NebulaWorkspace { .on_action(cx.listener(|this, _: &PasteClipboard, window, cx| { this.paste_focused_terminal(window, cx); })) + .on_action(cx.listener(|this, _: &super::keyboard_bindings::ClearTerminal, _, cx| { + this.clear_focused_terminal(cx); + })) .on_action(cx.listener(|this, _: &ToggleFullscreen, window, _cx| { window.toggle_fullscreen(); })) From 6d10f8c8573ca494c925bab9a9330b2f35c1b6d9 Mon Sep 17 00:00:00 2001 From: zher52 Date: Thu, 15 Oct 2026 16:04:58 +0800 Subject: [PATCH 2/6] test(keymap): drop the platform conditional from the clear-screen control group The platform-cfg ratchet counts cfg! macros outside platform/ and the clear-screen adapter regression added one. Build the static default keymap identically on every platform instead: the precedence being tested is source-tier, not platform-specific, and the macOS-only registration behavior stays guarded by its own runtime-platform test. --- .../gpui_shell/workspace/keyboard_bindings/tests.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/nebula_app/src/gpui_shell/workspace/keyboard_bindings/tests.rs b/nebula_app/src/gpui_shell/workspace/keyboard_bindings/tests.rs index 0c84d9fcf..458855108 100644 --- a/nebula_app/src/gpui_shell/workspace/keyboard_bindings/tests.rs +++ b/nebula_app/src/gpui_shell/workspace/keyboard_bindings/tests.rs @@ -129,12 +129,12 @@ fn clear_history_maps_to_the_clear_terminal_action() { ) .unwrap(), ]; + // 对照组:⌘K 的静态默认键(Shell 选择器)必须存在,否则「用户绑定的 + // 清屏压过静态默认键」的断言空跑。键表级构造三平台同形;macOS 上 + // ⌘ 别名只在运行时注册的平台差异由 + // `command_shortcut_registration_follows_runtime_platform` 单独守住。 let mut statics = default_workspace_bindings(); - if cfg!(target_os = "macos") { - // 对照组:macOS 的 ⌘K 静态默认键(Shell 选择器)必须存在, - // 否则「用户清屏压过默认键」的断言空跑。 - statics.push(custom_workspace_binding("cmd+k", &Action::ToggleShellPicker).unwrap()); - } + statics.push(custom_workspace_binding("cmd+k", &Action::ToggleShellPicker).unwrap()); let keymap = Keymap::new(with_user_keybindings(&Keymap::new(statics), user)); let contexts = [ From 94cdb39edd43528970b9bff3d0b11e4aa03ab5ba Mon Sep 17 00:00:00 2001 From: zher52 Date: Mon, 19 Oct 2026 10:35:12 +0800 Subject: [PATCH 3/6] test(keymap): expect the released Ctrl+K default after the #656 policy Upstream cf77ccd4 releases plain Control-character workspace defaults on macOS, so the Shell picker's Ctrl+K default may legitimately be absent. Derive the Ctrl+K expectation from the control group's own shape instead of asserting it unconditionally; the user clear binding must neither steal a present default nor resurrect a released key. --- .../workspace/keyboard_bindings/tests.rs | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/nebula_app/src/gpui_shell/workspace/keyboard_bindings/tests.rs b/nebula_app/src/gpui_shell/workspace/keyboard_bindings/tests.rs index 458855108..e569ace68 100644 --- a/nebula_app/src/gpui_shell/workspace/keyboard_bindings/tests.rs +++ b/nebula_app/src/gpui_shell/workspace/keyboard_bindings/tests.rs @@ -135,6 +135,15 @@ fn clear_history_maps_to_the_clear_terminal_action() { // `command_shortcut_registration_follows_runtime_platform` 单独守住。 let mut statics = default_workspace_bindings(); statics.push(custom_workspace_binding("cmd+k", &Action::ToggleShellPicker).unwrap()); + // macOS 上纯 Ctrl 字符默认键已被 #656 的平台策略释放(对照组表里可能 + // 没有它),所以 Ctrl+K 的期望形状以对照组自身为准,不写平台分支。 + let picker_owns_ctrl_k = statics.iter().any(|binding| { + binding.action().as_any().is::() + && binding.keystrokes().iter().any(|stroke| { + let stroke = stroke.inner(); + stroke.key == "k" && stroke.modifiers.control + }) + }); let keymap = Keymap::new(with_user_keybindings(&Keymap::new(statics), user)); let contexts = [ @@ -148,15 +157,19 @@ fn clear_history_maps_to_the_clear_terminal_action() { bindings[0].action().as_any().is::(), "用户绑定的清屏必须压过静态默认键" ); - // Shell 选择器自身的默认键不受影响:Ctrl+K 在所有平台都是它的 - // 工作区默认键,用户给 ⌘K 绑清屏不改变这一点。 + // Shell 选择器的 Ctrl+K 默认键不受用户清屏绑定影响(期望形状已在 + // 构造 keymap 前从对照组读取)。 let (bindings, pending) = keymap.bindings_for_input(&[Keystroke::parse("ctrl-k").unwrap()], &contexts); - assert!(!pending); - assert!( - bindings[0].action().as_any().is::(), - "清屏绑定不得挤占 Shell 选择器的默认键" - ); + assert_eq!(picker_owns_ctrl_k, !pending && !bindings.is_empty()); + if picker_owns_ctrl_k { + assert!( + bindings[0].action().as_any().is::(), + "清屏绑定不得挤占 Shell 选择器的 Ctrl+K 默认键" + ); + } else { + assert!(bindings.is_empty() && !pending, "被平台策略释放的 Ctrl+K 不得被清屏绑定重新占用"); + } } #[test] From 92516fe2b5265d0b7547b234f42d2a62f251b7a7 Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 19 Oct 2026 18:52:00 +0800 Subject: [PATCH 4/6] Verify clear-screen state transitions and preserve macOS shortcut ownership --- .../2026-10-14-terminal-clear-action.md | 25 ++---- nebula_app/src/display/keymap.rs | 19 +++++ .../gpui_shell/terminal/view/startup_tests.rs | 80 +++++++++++++++++++ 3 files changed, 107 insertions(+), 17 deletions(-) diff --git a/architecture/notes/nebula_app/gpui_shell/2026-10-14-terminal-clear-action.md b/architecture/notes/nebula_app/gpui_shell/2026-10-14-terminal-clear-action.md index 75f70809b..1e765ef33 100644 --- a/architecture/notes/nebula_app/gpui_shell/2026-10-14-terminal-clear-action.md +++ b/architecture/notes/nebula_app/gpui_shell/2026-10-14-terminal-clear-action.md @@ -46,8 +46,7 @@ established macOS ⌘K clear convention had no path to that behavior. gains a terminal-group row backed by `Action::ClearHistory` (a unit variant that already round-trips through `parse_action` / `action_storage_name`). - Semantics branch on the pane's foreground state after an instant app-side wipe - of the viewport and scrollback (`ClearMode::All` + `Saved`, zero-latency - feedback): + of the viewport and scrollback (`ClearMode::All` + `Saved`): - Shell at a prompt (OSC 133 `nebula_prompt_active`): write `\x0c` to that pane's PTY so the shell moves the cursor home and repaints the prompt on the fresh screen, keeping its line state in sync. @@ -96,18 +95,11 @@ established macOS ⌘K clear convention had no path to that behavior. ## Consequences -- On a fresh macOS install the Clear screen row displays `Command+K`, because - `effective_combo` reads the config table and `mods_prefix` renders `super` - natively — while runtime ⌘K actually opens the Shell picker. This is the same - config-table display convention recorded in the 2026-09-21 note ("the keymap - page still shows Ctrl keys for actions whose macOS binding set includes ⌘"), - not a new runtime claim. Capturing any key on the row writes a user row that - wins immediately. -- Resetting the Clear screen row also removes `cmd+k:ReceiveChar` lines - (`reset_action`'s vocabulary includes config-table defaults), which hands ⌘K - back to the Shell picker. Clearing the row writes `keybind=cmd+k:ReceiveChar`, - which shadows the picker's ⌘K — ordinary combo-level shadowing, identical to - every other row. +- The current GPUI shortcut policy assigns Command+K to the Shell picker and + removes competing legacy defaults. Clear screen therefore starts unbound on + macOS as well. Clearing or resetting an unbound Clear screen row does not + consume or release the picker's chord. An explicit user binding still wins + through the existing keymap ownership rules. - Windows/Linux gain an unbound row and no behavior change; existing default keybindings are untouched on every platform. - Files written by earlier builds hold no `ClearHistory` line — the old shell @@ -142,6 +134,5 @@ its alias-table authority and unbind vocabulary are reused unchanged. ## Revisit when -The keymap page renders platform-native or runtime-truth keycaps (removing the -Command+K display quirk), or the product decides clear screen deserves a default -key of its own. +The product decides clear screen deserves a default key of its own, or native +shell feedback shows that prompt redraw needs a different integration contract. diff --git a/nebula_app/src/display/keymap.rs b/nebula_app/src/display/keymap.rs index 7ebd34b86..a6867e757 100644 --- a/nebula_app/src/display/keymap.rs +++ b/nebula_app/src/display/keymap.rs @@ -922,6 +922,25 @@ mod tests { ); } + #[test] + #[cfg(target_os = "macos")] + fn clear_screen_does_not_claim_the_shell_picker_default() { + assert_eq!(GPUI.effective_combo(&Action::ClearHistory, &[]), None); + assert_eq!( + GPUI.effective_combo(&Action::ToggleShellPicker, &[]), + Some(("Command+K".into(), false)) + ); + assert!(LEGACY.effective_combo(&Action::ClearHistory, &[]).is_some()); + let mut raw = Vec::new(); + GPUI.clear_action(&mut raw, &Action::ClearHistory); + GPUI.reset_action(&mut raw, &Action::ClearHistory); + assert!(raw.is_empty()); + assert_eq!( + GPUI.effective_combo(&Action::ToggleShellPicker, &build_bindings(&raw)), + Some(("Command+K".into(), false)) + ); + } + #[test] fn frontend_policies_do_not_share_defaults_or_digit_identity() { use super::*; diff --git a/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs b/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs index 49b05eede..6592891e6 100644 --- a/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs +++ b/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs @@ -109,6 +109,86 @@ pub(super) fn feed(view: &mut TerminalView, bytes: &[u8]) { parser.advance(&mut *term, bytes); } +#[gpui::test] +fn clear_screen_clears_history_and_writes_redraw_only_at_prompt(cx: &mut TestAppContext) { + let (view, cx, receiver) = open(cx); + for prompt in [false, true] { + view.update(cx, |view, cx| { + for _ in 0..40 { + feed(view, b"previous output\r\n"); + } + { + let mut term = view.session.as_ref().unwrap().term.lock(); + assert!(term.history_size() > 0); + term.selection = Some(Selection::new( + SelectionType::Simple, + TermPoint::new(Line(0), Column(0)), + Side::Left, + )); + if prompt { + term.nebula_add_prompt_mark(); + } else { + term.nebula_end_prompt(); + } + } + receiver.try_iter().for_each(drop); + view.clear_screen(cx); + let term = view.session.as_ref().unwrap().term.lock(); + assert_eq!(term.history_size(), 0); + assert_eq!(term.grid().display_offset(), 0); + assert!(term.selection.is_none()); + assert!(term.grid().display_iter().all(|cell| cell.c == ' ')); + if !prompt { + assert_eq!(term.grid().cursor.point, TermPoint::new(Line(0), Column(0))); + } + }); + let writes: Vec<_> = receiver + .try_iter() + .filter_map(|message| match message { + Msg::Input(bytes) => Some(bytes.into_owned()), + _ => None, + }) + .collect(); + assert_eq!(writes, if prompt { vec![b"\x0c".to_vec()] } else { vec![] }); + } +} + +#[gpui::test] +fn clear_screen_keeps_alternate_screen_and_primary_history(cx: &mut TestAppContext) { + let (view, cx, receiver) = open(cx); + view.update(cx, |view, cx| { + for _ in 0..40 { + feed(view, b"primary output\r\n"); + } + let history = view.session.as_ref().unwrap().term.lock().history_size(); + assert!(history > 0); + feed(view, b"\x1b[?1049hfull-screen application"); + let before = { + let term = view.session.as_ref().unwrap().term.lock(); + ( + term.grid().cursor.point, + term.grid().display_iter().map(|cell| cell.c).collect::(), + ) + }; + receiver.try_iter().for_each(drop); + view.clear_screen(cx); + { + let term = view.session.as_ref().unwrap().term.lock(); + assert!(term.mode().contains(TermMode::ALT_SCREEN)); + assert_eq!( + before, + ( + term.grid().cursor.point, + term.grid().display_iter().map(|cell| cell.c).collect::() + ) + ); + } + assert!(receiver.try_iter().all(|message| !matches!(message, Msg::Input(_)))); + feed(view, b"\x1b[?1049l"); + assert_eq!(view.session.as_ref().unwrap().term.lock().history_size(), history); + }); +} + fn refresh_completion_from_grid(view: &mut TerminalView, cx: &mut Context) { let (line, anchor) = { let term = view.session.as_ref().unwrap().term.lock(); From db35ebd56491c462f112ed24f05cdf7c0f215f63 Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 19 Oct 2026 19:24:39 +0800 Subject: [PATCH 5/6] Keep clear-screen policy test independent of legacy defaults --- nebula_app/src/display/keymap.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/nebula_app/src/display/keymap.rs b/nebula_app/src/display/keymap.rs index a6867e757..6fdb1828a 100644 --- a/nebula_app/src/display/keymap.rs +++ b/nebula_app/src/display/keymap.rs @@ -930,7 +930,6 @@ mod tests { GPUI.effective_combo(&Action::ToggleShellPicker, &[]), Some(("Command+K".into(), false)) ); - assert!(LEGACY.effective_combo(&Action::ClearHistory, &[]).is_some()); let mut raw = Vec::new(); GPUI.clear_action(&mut raw, &Action::ClearHistory); GPUI.reset_action(&mut raw, &Action::ClearHistory); From 06fbd22ca8b1bc4af3451bf2118e7db4449e73cd Mon Sep 17 00:00:00 2001 From: zher52 Date: Thu, 22 Oct 2026 09:03:59 +0800 Subject: [PATCH 6/6] ci: retrigger the flaky Windows bash-input native test