Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
932ce2a
feat(gpui): a bindable clear-screen action for the terminal
zher52 Oct 15, 2026
6d10f8c
test(keymap): drop the platform conditional from the clear-screen con…
zher52 Oct 15, 2026
94cdb39
test(keymap): expect the released Ctrl+K default after the #656 policy
zher52 Oct 19, 2026
30fdc81
Merge commit 'b49b53515d3e6513e363de7f842436f23680e614' into review/c…
Kuddev Oct 19, 2026
92516fe
Verify clear-screen state transitions and preserve macOS shortcut own…
Kuddev Oct 19, 2026
db35ebd
Keep clear-screen policy test independent of legacy defaults
Kuddev Oct 19, 2026
5bf5974
Merge remote-tracking branch 'origin/main' into review/clear-history-653
Kuddev Oct 19, 2026
d29d203
Merge branch 'main' into feat/gpui-clear-terminal-action
zher52 Oct 21, 2026
dab38ca
Merge branch 'main' into feat/gpui-clear-terminal-action
zher52 Oct 21, 2026
06fbd22
ci: retrigger the flaky Windows bash-input native test
zher52 Oct 22, 2026
d08d739
Merge branch 'main' into feat/gpui-clear-terminal-action
zher52 Oct 23, 2026
4022ff2
Merge remote-tracking branch 'origin/main' into feat/gpui-clear-termi…
zher52 Oct 26, 2026
32dc755
Merge branch 'main' into feat/gpui-clear-terminal-action
zher52 Oct 26, 2026
039c0fd
Merge branch 'main' into feat/gpui-clear-terminal-action
zher52 Oct 26, 2026
3ff04c8
Merge branch 'main' into feat/gpui-clear-terminal-action
zher52 Oct 26, 2026
c1981c2
Merge branch 'main' into feat/gpui-clear-terminal-action
zher52 Oct 27, 2026
2d4329c
Merge branch 'main' into feat/gpui-clear-terminal-action
zher52 Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 <PR 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.
1 change: 1 addition & 0 deletions nebula_app/i18n/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,7 @@
"edit": "Edit",
"rename": "Rename",
"rename_tab": "Rename tab",
"clear_screen": "Clear screen",
"copy": "Copy",
"paste": "Paste",
"cut": "Cut",
Expand Down
1 change: 1 addition & 0 deletions nebula_app/i18n/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,7 @@
"edit": "编辑",
"rename": "重命名",
"rename_tab": "重命名标签页",
"clear_screen": "清屏",
"copy": "复制",
"paste": "粘贴",
"cut": "剪切",
Expand Down
39 changes: 38 additions & 1 deletion nebula_app/src/display/keymap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:只用段标题+间距分组,不画容器)。
Expand All @@ -78,7 +81,7 @@ pub(crate) const GROUPS: &[(&str, &str, usize)] = &[
("标签页", "Tabs", 5),
("窗格", "Panes", 7),
("侧栏面板", "Side panels", 2),
("终端", "Terminal", 9),
("终端", "Terminal", 10),
];

/// 只读展示行(无法在图形页编辑,TOML/settings 行仍可覆盖其中的表驱动键)。
Expand Down Expand Up @@ -215,6 +218,7 @@ fn action_message(action: &Action) -> Option<crate::i18n::Message> {
Action::IncreaseFontSize => Message::SettingsKeymapActionFontSizeUp,
Action::DecreaseFontSize => Message::SettingsKeymapActionFontSizeDown,
Action::ResetFontSize => Message::SettingsKeymapActionResetFontSize,
Action::ClearHistory => Message::CommonClearScreen,
_ => return None,
})
}
Expand Down Expand Up @@ -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::*;
Expand Down Expand Up @@ -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::*;
Expand Down
28 changes: 27 additions & 1 deletion nebula_app/src/gpui_shell/terminal/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<Self>) {
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>) {
self.paste_now_impl(text, true, cx);
}
Expand Down
80 changes: 80 additions & 0 deletions nebula_app/src/gpui_shell/terminal/view/startup_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<String>(),
)
};
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::<String>()
)
);
}
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<TerminalView>) {
let (line, anchor) = {
let term = view.session.as_ref().unwrap().term.lock();
Expand Down
9 changes: 9 additions & 0 deletions nebula_app/src/gpui_shell/workspace/key_actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self>) {
if let Some(view) = self.tabs.get(self.active).and_then(WorkspaceTab::focused_view) {
view.update(cx, |view, cx| view.clear_screen(cx));
}
}
}
14 changes: 14 additions & 0 deletions nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self> {
use crate::config::Action;
Expand Down Expand Up @@ -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。
Expand Down
Loading
Loading