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..1e765ef33 --- /dev/null +++ b/architecture/notes/nebula_app/gpui_shell/2026-10-14-terminal-clear-action.md @@ -0,0 +1,138 @@ +# 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`): + - 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 + +- 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 + 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 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/i18n/en-US.json b/nebula_app/i18n/en-US.json index 3317da804..ac5baa93d 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -688,6 +688,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 ce2e47645..46256d6c3 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -688,6 +688,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 876d8fe92..9b1e227d6 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 行仍可覆盖其中的表驱动键)。 @@ -215,6 +218,7 @@ fn action_message(action: &Action) -> Option { Action::IncreaseFontSize => Message::SettingsKeymapActionFontSizeUp, Action::DecreaseFontSize => Message::SettingsKeymapActionFontSizeDown, Action::ResetFontSize => Message::SettingsKeymapActionResetFontSize, + Action::ClearHistory => Message::CommonClearScreen, _ => return None, }) } @@ -975,6 +979,24 @@ 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)) + ); + 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::*; @@ -1066,6 +1088,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 a54e4aa6a..ca4422e8d 100644 --- a/nebula_app/src/gpui_shell/terminal/view.rs +++ b/nebula_app/src/gpui_shell/terminal/view.rs @@ -55,7 +55,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; @@ -1251,6 +1251,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/terminal/view/startup_tests.rs b/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs index 6fb593418..c25683aee 100644 --- a/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs +++ b/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs @@ -110,6 +110,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(); diff --git a/nebula_app/src/gpui_shell/workspace/key_actions.rs b/nebula_app/src/gpui_shell/workspace/key_actions.rs index 44d5cbcee..8545bddb5 100644 --- a/nebula_app/src/gpui_shell/workspace/key_actions.rs +++ b/nebula_app/src/gpui_shell/workspace/key_actions.rs @@ -142,4 +142,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..e569ace68 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,70 @@ 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(), + ]; + // 对照组:⌘K 的静态默认键(Shell 选择器)必须存在,否则「用户绑定的 + // 清屏压过静态默认键」的断言空跑。键表级构造三平台同形;macOS 上 + // ⌘ 别名只在运行时注册的平台差异由 + // `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 = [ + 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 默认键不受用户清屏绑定影响(期望形状已在 + // 构造 keymap 前从对照组读取)。 + let (bindings, pending) = + keymap.bindings_for_input(&[Keystroke::parse("ctrl-k").unwrap()], &contexts); + 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] fn cleared_shortcut_reaches_terminal_and_can_be_restored_without_restart() { use crate::config::Action; @@ -936,6 +1000,7 @@ mod dispatch { focus: FocusHandle, selected: Option, renamed: usize, + cleared: usize, mode: TermMode, input: Vec, encoded: Vec, @@ -949,6 +1014,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 +1048,7 @@ mod dispatch { focus: cx.focus_handle(), selected: None, renamed: 0, + cleared: 0, mode: TermMode::empty(), input: Vec::new(), encoded: Vec::new(), @@ -1071,6 +1138,7 @@ mod dispatch { focus: cx.focus_handle(), selected: None, renamed: 0, + cleared: 0, mode: TermMode::empty(), input: Vec::new(), encoded: Vec::new(), @@ -1109,4 +1177,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 2492451b0..0f8172291 100644 --- a/nebula_app/src/gpui_shell/workspace/render.rs +++ b/nebula_app/src/gpui_shell/workspace/render.rs @@ -246,6 +246,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(); }))