merge: include completion isolation and scrolling controls in 1.8.1
Preserve the committed memory and scrolling work from local main together with update recovery and the default font rollback. Resolve terminal initialization at its new lifecycle owner and keep native WSL lookup in the platform layer. Document the seven history limits, wheel-speed settings, allocation tradeoffs and connection-specific completion behavior in both release-note languages.
This commit is contained in:
commit
86128e4309
48 files changed
+2002
-327
No files matched your search
@@ -10,11 +10,14 @@ Every release entry is provided in English and Simplified Chinese.
|
||||
|
||||
#### Added
|
||||
|
||||
- Added seven scrollback limits: 1,000 / 2,000 / 5,000 / 10,000 / 20,000 / 50,000 / 100,000 lines. The default remains 10,000; changes apply to new terminals without truncating open sessions.
|
||||
- Added a wheel-speed control from 0.25× to 4.00×, defaulting to 1.00×. Dragging previews the speed and releasing saves it; trackpad pixel scrolling, font zoom and completion-list scrolling keep their existing behavior.
|
||||
- Added a custom theme library with saved snapshots, a preview editor, and theme import/export. Saved themes can retain terminal colors, typography and window appearance alongside the built-in themes.
|
||||
- Added optional background update downloads and an option to install a verified download on the next application launch on Windows.
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Fixed command history and completion candidates leaking between the parent shell and typed SSH/WSL connections. Integrated shell reports switch the active context and restore it on return; native PowerShell predictions retain the user's configuration.
|
||||
- Restored the default terminal font to the exact Maple Mono Normal NF CN font bundled with 1.7.0 after the default changed in 1.8.0. Explicitly selected custom fonts remain available. Addresses [#148](https://github.com/Kuddev/nebula/issues/148).
|
||||
- Fixed update checks and installer downloads ignoring the application's proxy configuration, including proxy exclusions and SOCKS connections.
|
||||
- Fixed scheduled updates merging saved windows into one window. Update recovery retains each window's tabs and active tab; this does not change desktop position or window-size restoration.
|
||||
@@ -23,6 +26,7 @@ Every release entry is provided in English and Simplified Chinese.
|
||||
|
||||
#### Improved
|
||||
|
||||
- Reduced unused scrollback allocations and reclaimed oversized row capacity when narrowing terminals, while preserving retained history and content. Large width reductions can take longer during synchronous reclamation; savings depend on the workload.
|
||||
- Improved Pi conversation recovery by retaining the native session ID and session file together with each pane's shell and working directory. Failed restoration keeps the original target available for retry instead of treating command submission as successful recovery. Addresses [#146](https://github.com/Kuddev/nebula/issues/146).
|
||||
- Improved Windows update handoff: save workspace state before exit, wait for the old process to stop before installing, verify the installer, and retain failure details when installation or restart does not complete.
|
||||
- Improved cleanup of terminals on Windows when panes close, including console resources and process-monitoring handles; retained font ownership while text rendering still needs it.
|
||||
@@ -33,11 +37,14 @@ Windows packages include a Pebrel installer and portable ZIP. Linux x64 packages
|
||||
|
||||
#### 新增
|
||||
|
||||
- 新增:七档回滚行数:1,000 / 2,000 / 5,000 / 10,000 / 20,000 / 50,000 / 100,000。默认仍为 10,000,仅影响新建终端,不截断已打开会话的历史。
|
||||
- 新增:0.25×~4.00× 滚轮速度控制,默认 1.00×;拖动即时预览,松手保存。触控板像素滚动、字体缩放和补全列表滚动保持原有行为。
|
||||
- 新增:自定义主题库,支持保存主题快照、预览编辑和主题导入导出;可在内置主题之外保存终端配色、字体排版及窗口外观。
|
||||
- 新增:Windows 可选的后台更新下载,以及在下次启动应用时安装已校验下载包的选项。
|
||||
|
||||
#### 修复
|
||||
|
||||
- 修复:父 Shell 与手动进入的 SSH/WSL 连接之间混用命令历史和补全候选的问题;根据集成 Shell 的回执切换当前环境,返回后恢复父环境,并保留用户配置的 PowerShell 原生预测。
|
||||
- 修复:将 1.8.0 更换后的默认终端字体恢复为与 1.7.0 完全相同的内置 Maple Mono Normal NF CN;用户明确选择的自定义字体仍可使用。对应 [#148](https://github.com/Kuddev/nebula/issues/148)。
|
||||
- 修复:检查更新和下载安装器时忽略应用代理配置的问题,包括代理排除规则与 SOCKS 连接。
|
||||
- 修复:计划更新将已保存的多个窗口合并成一个窗口的问题;更新恢复时保留各窗口的标签及活动标签,不改变桌面位置或窗口尺寸的恢复行为。
|
||||
@@ -46,6 +53,7 @@ Windows packages include a Pebrel installer and portable ZIP. Linux x64 packages
|
||||
|
||||
#### 改进
|
||||
|
||||
- 改进:减少闲置回滚行的预分配,并在缩窄终端时回收过大的行容量,保留历史行数及内容;大幅缩列时同步回收可能增加耗时,节省量取决于实际负载。
|
||||
- 改进:Pi 会话恢复同时保留原生会话 ID、会话文件,以及各窗格的 Shell 和工作目录。恢复失败时保留原目标以便重试,不再仅凭恢复命令已提交就视为恢复成功。对应 [#146](https://github.com/Kuddev/nebula/issues/146)。
|
||||
- 改进:Windows 更新交接在退出前保存工作区,等待旧进程退出后再安装,校验安装包,并在安装或重启未完成时保留失败详情。
|
||||
- 改进:Windows 关闭窗格时释放控制台资源及进程监测句柄,并在文字渲染仍需使用字体时保持其有效。
|
||||
|
||||
@@ -572,7 +572,90 @@ settings files.
|
||||
provide equivalent process identity and resource-lifetime guarantees.
|
||||
|
||||
|
||||
## ADR-0016 — Durable recovery targets and update handoff
|
||||
## ADR-0016 — Completion ownership follows the active connection
|
||||
|
||||
- **Date:** 2026-09-15
|
||||
- **Context:** Launch-time Local/WSL/SSH pools do not follow a typed SSH/WSL
|
||||
connection. History and directory candidates consequently retain the outer
|
||||
pane's source. A remote command-done marker does not mean SSH has exited.
|
||||
- **Decision:** Keep one shared completion context for both UI adapters. Record
|
||||
the connection command in the parent scope, then select a separate connection
|
||||
scope for subsequent commands. A known parent shell's prompt restores its scope.
|
||||
Clear ghost/popup candidates, dismissal state, input mirrors, cwd and pending
|
||||
directory requests on scope changes. Every cache/result retains its environment.
|
||||
Local directory history only accepts local reports. Empty/unreadable input and
|
||||
ordinary shell commands do not disable completion or learning.
|
||||
- **Shell adapter:** Reuse OSC 1337 SetUserVar. `pebrel_shell` identifies a shell
|
||||
instance at its prompt. `pebrel_command` carries that instance and PSReadLine's
|
||||
accepted command, including simple PowerShell alias resolution. This handles
|
||||
recall and completed input that the key mirror cannot reconstruct. The SSH/WSL
|
||||
execution wrapper reports expanded argv as NUL-separated `pebrel_connection`
|
||||
fields, then restores the owner when the actual process returns. Thus a variable
|
||||
such as `$targetHost` does not merge different destinations, and return inside a
|
||||
compound command does not wait for the next prompt. PowerShell invokes the
|
||||
native application; bash/zsh share one payload and preserve user wrappers and
|
||||
redirected command output. Integration tokens are generated once per shell and remain unexported. Existing local
|
||||
PowerShell/bash/zsh, WSL bash hooks and SSH bootstrap hooks carry these signals.
|
||||
Preserve terminal event order across chunks so a cwd cannot move past the
|
||||
parent-context report. A new shell on the same host keeps that host's history.
|
||||
- **Connection identity:** Existing JSONL files/schema remain authoritative. Typed
|
||||
SSH/WSL contexts use a `typed:` SHA-256 key in their respective history category,
|
||||
incorporating the parent history scope and argument boundaries. This separates
|
||||
bastion/config/port routes without reusing an outer SFTP channel for an inner
|
||||
host. Typed-connection path completion stays with the native shell. WSL launch
|
||||
resolves the default distro from Windows' Lxss registry; an unavailable name
|
||||
stays a distinct WSL scope, never Local, and its shell report can supply the
|
||||
actual distro. No new service or dependency is added.
|
||||
- **Native completion:** Preserve PSReadLine prediction settings. The existing
|
||||
grid reconciliation yields when shell text occupies the space after the cursor,
|
||||
so native inline predictions keep their own acceptance keys. The Pebrel toggle
|
||||
only controls its candidates; it does not reconfigure the shell editor.
|
||||
- **Scope:** This change addresses connection history and candidate ownership.
|
||||
It adds no deletion, exit-status filtering, command blacklist or input-quality
|
||||
learning switch. Existing records without provenance are retained. Return from
|
||||
nested connections is verified with parent shell integration; it is not inferred
|
||||
from an untagged exit code or claimed to cover arbitrary uninstrumented wrappers.
|
||||
- **Validation:** Regressions cover local/SSH/WSL entry, parent return, nested
|
||||
reported shells, failed connections, options/ancestry separation, native accepted
|
||||
commands, same-host shells and candidate invalidation. Test delayed directory
|
||||
results against a changed context and parser order at every byte split. Native
|
||||
PowerShell hook tests validate emitted identity, accepted alias text and native
|
||||
prediction preservation. A Windows integration test sends execution/return
|
||||
reports through the production ConPTY and parses the resulting byte stream. Report
|
||||
actual executed checks separately from live remote-server validation.
|
||||
|
||||
## ADR-0017 — Scrollback allocation and user scrolling preferences
|
||||
|
||||
- **Date:** 2026-09-15
|
||||
- **Context:** The first history expansion initialized at least 1,000 full-width
|
||||
rows per grid. Large column reductions retained oversized cell vectors. Users
|
||||
requested lower memory without reducing retained history or scrolling behavior.
|
||||
- **Decision:** Keep history limits and ring indexing intact. Initialize ahead by
|
||||
one viewport, bounded to 32–128 rows, and reclaim surplus against the current
|
||||
viewport. During row shrinking, release capacity only when at least 32 cells and
|
||||
half the allocation are unused. Retain enough space for short reflow tails to
|
||||
reach the destination width without immediately growing again.
|
||||
- **Cost:** Smaller batches increase ring normalization frequency; vector growth
|
||||
is still geometric. Row reclamation runs synchronously during resize and can
|
||||
increase a large shrink's latency. Allocation reduction is not a working-set or
|
||||
universal throughput guarantee. Existing-history scrolling does not allocate
|
||||
new history rows. Compare identical content, geometry and build profiles.
|
||||
- **Preferences:** `nebula_settings` owns additive `scrollback_lines` and
|
||||
`scroll_speed` keys, validation and defaults. History offers seven values from
|
||||
1,000 to 100,000, defaults to 10,000 and is passed only to newly created terminal
|
||||
sessions; changing it cannot truncate open sessions. Wheel speed defaults to
|
||||
1.0 and is bounded to 0.25–4.0. GPUI reads its cached value, retains fractional
|
||||
input and leaves pixel-precise trackpad input, font zoom and completion scrolling
|
||||
independent. Dragging previews speed; release persists it with failure feedback.
|
||||
No smoothing timer, new dependency, worker or terminal persistence format is added.
|
||||
- **Validation:** Cover rotated growth/reclamation, threshold boundaries, reflow
|
||||
content/cursor space, preference round trips/reset and actual selector/slider
|
||||
interactions. Record native product, allocation and timing results separately.
|
||||
- **Revisit condition:** Reconsider batching or deferred reclamation if measured
|
||||
large-history output or resize latency becomes unacceptable; preserve the same
|
||||
history, ordering and input contracts when evaluating alternatives.
|
||||
|
||||
## ADR-0018 — Durable recovery targets and update handoff
|
||||
|
||||
- **Status:** Implementation authorized by the maintainer on 2026-09-15; native
|
||||
upgrade acceptance is required before declaring this behavior delivered.
|
||||
@@ -625,7 +708,7 @@ settings files.
|
||||
must replace the executable bytes; an unchanged executable after setup exits
|
||||
zero is a failure. This does not substitute for a full packaged upgrade test.
|
||||
|
||||
## ADR-0017 — Optional release contributor section
|
||||
## ADR-0019 — Optional release contributor section
|
||||
|
||||
- **Status:** Approved by the maintainer on 2026-09-15.
|
||||
- **Context:** A release without new PR contributors previously failed validation
|
||||
|
||||
@@ -4,11 +4,14 @@
|
||||
|
||||
### Added
|
||||
|
||||
- Added seven scrollback limits: 1,000 / 2,000 / 5,000 / 10,000 / 20,000 / 50,000 / 100,000 lines. The default remains 10,000; changes apply to new terminals without truncating open sessions.
|
||||
- Added a wheel-speed control from 0.25× to 4.00×, defaulting to 1.00×. Dragging previews the speed and releasing saves it; trackpad pixel scrolling, font zoom and completion-list scrolling keep their existing behavior.
|
||||
- Added a custom theme library with saved snapshots, a preview editor, and theme import/export. Saved themes can retain terminal colors, typography and window appearance alongside the built-in themes.
|
||||
- Added optional background update downloads and an option to install a verified download on the next application launch on Windows.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed command history and completion candidates leaking between the parent shell and typed SSH/WSL connections. Integrated shell reports switch the active context and restore it on return; native PowerShell predictions retain the user's configuration.
|
||||
- Restored the default terminal font to the exact Maple Mono Normal NF CN font bundled with 1.7.0 after the default changed in 1.8.0. Explicitly selected custom fonts remain available. Addresses [#148](https://github.com/Kuddev/nebula/issues/148).
|
||||
- Fixed update checks and installer downloads ignoring the application's proxy configuration, including proxy exclusions and SOCKS connections.
|
||||
- Fixed scheduled updates merging saved windows into one window. Update recovery retains each window's tabs and active tab; this does not change desktop position or window-size restoration.
|
||||
@@ -17,6 +20,7 @@
|
||||
|
||||
### Improved
|
||||
|
||||
- Reduced unused scrollback allocations and reclaimed oversized row capacity when narrowing terminals, while preserving retained history and content. Large width reductions can take longer during synchronous reclamation; savings depend on the workload.
|
||||
- Improved Pi conversation recovery by retaining the native session ID and session file together with each pane's shell and working directory. Failed restoration keeps the original target available for retry instead of treating command submission as successful recovery. Addresses [#146](https://github.com/Kuddev/nebula/issues/146).
|
||||
- Improved Windows update handoff: save workspace state before exit, wait for the old process to stop before installing, verify the installer, and retain failure details when installation or restart does not complete.
|
||||
- Improved cleanup of terminals on Windows when panes close, including console resources and process-monitoring handles; retained font ownership while text rendering still needs it.
|
||||
@@ -27,11 +31,14 @@ Windows packages include a Pebrel installer and portable ZIP. Linux x64 packages
|
||||
|
||||
### 新增
|
||||
|
||||
- 新增:七档回滚行数:1,000 / 2,000 / 5,000 / 10,000 / 20,000 / 50,000 / 100,000。默认仍为 10,000,仅影响新建终端,不截断已打开会话的历史。
|
||||
- 新增:0.25×~4.00× 滚轮速度控制,默认 1.00×;拖动即时预览,松手保存。触控板像素滚动、字体缩放和补全列表滚动保持原有行为。
|
||||
- 新增:自定义主题库,支持保存主题快照、预览编辑和主题导入导出;可在内置主题之外保存终端配色、字体排版及窗口外观。
|
||||
- 新增:Windows 可选的后台更新下载,以及在下次启动应用时安装已校验下载包的选项。
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复:父 Shell 与手动进入的 SSH/WSL 连接之间混用命令历史和补全候选的问题;根据集成 Shell 的回执切换当前环境,返回后恢复父环境,并保留用户配置的 PowerShell 原生预测。
|
||||
- 修复:将 1.8.0 更换后的默认终端字体恢复为与 1.7.0 完全相同的内置 Maple Mono Normal NF CN;用户明确选择的自定义字体仍可使用。对应 [#148](https://github.com/Kuddev/nebula/issues/148)。
|
||||
- 修复:检查更新和下载安装器时忽略应用代理配置的问题,包括代理排除规则与 SOCKS 连接。
|
||||
- 修复:计划更新将已保存的多个窗口合并成一个窗口的问题;更新恢复时保留各窗口的标签及活动标签,不改变桌面位置或窗口尺寸的恢复行为。
|
||||
@@ -40,6 +47,7 @@ Windows packages include a Pebrel installer and portable ZIP. Linux x64 packages
|
||||
|
||||
### 改进
|
||||
|
||||
- 改进:减少闲置回滚行的预分配,并在缩窄终端时回收过大的行容量,保留历史行数及内容;大幅缩列时同步回收可能增加耗时,节省量取决于实际负载。
|
||||
- 改进:Pi 会话恢复同时保留原生会话 ID、会话文件,以及各窗格的 Shell 和工作目录。恢复失败时保留原目标以便重试,不再仅凭恢复命令已提交就视为恢复成功。对应 [#146](https://github.com/Kuddev/nebula/issues/146)。
|
||||
- 改进:Windows 更新交接在退出前保存工作区,等待旧进程退出后再安装,校验安装包,并在安装或重启未完成时保留失败详情。
|
||||
- 改进:Windows 关闭窗格时释放控制台资源及进程监测句柄,并在文字渲染仍需使用字体时保持其有效。
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
"en_us": "English"
|
||||
},
|
||||
"settings": {
|
||||
"scrolling": {
|
||||
"history": "Scrollback lines",
|
||||
"history_description": "History lines retained per terminal. Applies to new terminals only.",
|
||||
"speed": "Scrolling speed",
|
||||
"speed_description": "Mouse wheel multiplier. Takes effect immediately; trackpad scrolling is unchanged."
|
||||
},
|
||||
"sidebar": {
|
||||
"application": "Application",
|
||||
"appearance": "Appearance",
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
"en_us": "English"
|
||||
},
|
||||
"settings": {
|
||||
"scrolling": {
|
||||
"history": "回滚行数",
|
||||
"history_description": "每个窗格保留的历史行数。仅适用于新窗格。",
|
||||
"speed": "滚动速度",
|
||||
"speed_description": "鼠标滚轮滚动的倍率。即时生效,不影响触控板滚动。"
|
||||
},
|
||||
"sidebar": {
|
||||
"application": "应用",
|
||||
"appearance": "外观",
|
||||
|
||||
@@ -2,6 +2,8 @@ if [[ -r $HOME/.bashrc ]]; then
|
||||
source "$HOME/.bashrc"
|
||||
fi
|
||||
|
||||
__pebrel_shell_token=$(printf '%s' "${WSL_DISTRO_NAME:+wsl|${WSL_DISTRO_NAME}|}bash:${HOSTNAME:-localhost}:${BASHPID:-$$}:$RANDOM" | base64 | tr -d '\r\n')
|
||||
|
||||
_nebula_cwd() {
|
||||
local LC_ALL=C cwd_value=$PWD encoded='' character ordinal index
|
||||
for ((index=0; index<${#cwd_value}; index++)); do
|
||||
@@ -32,6 +34,7 @@ _nebula_capture_status() {
|
||||
}
|
||||
|
||||
_nebula_precmd() {
|
||||
printf '\e]1337;SetUserVar=pebrel_shell=%s\a' "$__pebrel_shell_token"
|
||||
if [[ ${_nebula_command_running:-0} == 1 ]]; then
|
||||
printf '\e]133;D;%s\a' "$_nebula_last_status"
|
||||
fi
|
||||
|
||||
@@ -8,6 +8,8 @@ if [[ -r ${ZDOTDIR-$HOME}/.zshrc ]]; then
|
||||
fi
|
||||
unset NEBULA_ZDOTDIR_WAS_SET NEBULA_ORIGINAL_ZDOTDIR NEBULA_ZSH_INTEGRATION
|
||||
|
||||
__pebrel_shell_token=$(printf '%s' "${WSL_DISTRO_NAME:+wsl|${WSL_DISTRO_NAME}|}zsh:${HOST:-localhost}:$$:$RANDOM" | base64 | tr -d '\r\n')
|
||||
|
||||
_nebula_cwd() {
|
||||
emulate -L zsh
|
||||
local LC_ALL=C cwd_value=$PWD encoded='' character ordinal index
|
||||
@@ -28,6 +30,7 @@ _nebula_preexec() {
|
||||
|
||||
_nebula_precmd() {
|
||||
local status_code=$?
|
||||
printf '\e]1337;SetUserVar=pebrel_shell=%s\a' "$__pebrel_shell_token"
|
||||
if [[ ${_nebula_command_running:-0} == 1 ]]; then
|
||||
printf '\e]133;D;%s\a' "$status_code"
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
//! Completion history follows the active SSH/WSL context, not the tab's launch label.
|
||||
//! Shell instance reports restore an enclosing context without confusing a remote
|
||||
//! command's OSC 133;D with the end of the SSH connection.
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use crate::display::{NebulaPaneState, SuggestEnv};
|
||||
use crate::nebula_history::{HistoryScope, NebulaHistory};
|
||||
|
||||
pub(crate) const SHELL_VAR: &str = "pebrel_shell";
|
||||
pub(crate) const COMMAND_VAR: &str = "pebrel_command";
|
||||
pub(crate) const ARGV_VAR: &str = "pebrel_connection";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Frame {
|
||||
token: Option<String>,
|
||||
env: SuggestEnv,
|
||||
cwd: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct CompletionContext {
|
||||
frames: Vec<Frame>,
|
||||
}
|
||||
|
||||
impl CompletionContext {
|
||||
fn submit(&mut self, env: &SuggestEnv, cwd: &str, line: &str) -> Option<SuggestEnv> {
|
||||
self.submit_argv(env, cwd, &crate::ssh::command_words(line)?)
|
||||
}
|
||||
|
||||
fn submit_argv(&mut self, env: &SuggestEnv, cwd: &str, words: &[String]) -> Option<SuggestEnv> {
|
||||
let child = connection_argv_environment(env, words)?;
|
||||
if self.frames.is_empty() {
|
||||
self.frames.push(Frame { token: None, env: env.clone(), cwd: cwd.to_owned() });
|
||||
} else if let Some(frame) = self.frames.last_mut() {
|
||||
frame.cwd = cwd.to_owned();
|
||||
}
|
||||
self.frames.push(Frame { token: None, env: child.clone(), cwd: String::new() });
|
||||
Some(child)
|
||||
}
|
||||
|
||||
fn prompt(&mut self, env: &SuggestEnv, cwd: &str, token: &str) -> Option<(SuggestEnv, String)> {
|
||||
if token.is_empty() || token.len() > 256 || token.chars().any(char::is_control) {
|
||||
return None;
|
||||
}
|
||||
if let Some(index) =
|
||||
self.frames.iter().position(|frame| frame.token.as_deref() == Some(token))
|
||||
{
|
||||
self.frames.truncate(index + 1);
|
||||
let frame = &self.frames[index];
|
||||
return Some((frame.env.clone(), frame.cwd.clone()));
|
||||
}
|
||||
let reported_env = match (env, token.strip_prefix("wsl|").and_then(|s| s.split_once('|'))) {
|
||||
(SuggestEnv::Wsl { distro }, Some((name, _)))
|
||||
if distro.is_empty() && !name.is_empty() =>
|
||||
{
|
||||
SuggestEnv::Wsl { distro: name.to_owned() }
|
||||
},
|
||||
_ => env.clone(),
|
||||
};
|
||||
if let Some(frame) = self.frames.last_mut()
|
||||
&& frame.token.is_none()
|
||||
{
|
||||
frame.token = Some(token.to_owned());
|
||||
frame.env = reported_env.clone();
|
||||
} else {
|
||||
// A new shell on the current host retains that host's history.
|
||||
// Only an SSH/WSL context change changes the source of suggestions.
|
||||
self.frames.push(Frame {
|
||||
token: Some(token.to_owned()),
|
||||
env: reported_env.clone(),
|
||||
cwd: cwd.to_owned(),
|
||||
});
|
||||
}
|
||||
(reported_env != *env).then(|| (reported_env, cwd.to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
impl NebulaPaneState {
|
||||
pub(crate) fn record_completion_command(&mut self, history: &mut NebulaHistory, line: &str) {
|
||||
history.record(&self.suggest_env.history_scope(), line, &self.cwd);
|
||||
self.completion_submitted(line);
|
||||
}
|
||||
|
||||
pub(crate) fn completion_submitted(&mut self, line: &str) {
|
||||
if let Some(env) = self.completion_context.submit(&self.suggest_env, &self.cwd, line) {
|
||||
self.switch_completion_environment(env, String::new());
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn completion_shell_report(&mut self, name: &str, value: &str) {
|
||||
if name == COMMAND_VAR || name == ARGV_VAR {
|
||||
let Some((token, line)) = value.split_once('\n') else { return };
|
||||
let Some(index) = self
|
||||
.completion_context
|
||||
.frames
|
||||
.iter()
|
||||
.position(|frame| frame.token.as_deref() == Some(token))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// PSReadLine reports the submitted text and its owning shell. This
|
||||
// also reconciles native recall/alias expansion with an Enter that
|
||||
// already changed the provisional completion context.
|
||||
self.completion_context.frames.truncate(index + 1);
|
||||
let frame = self.completion_context.frames[index].clone();
|
||||
if frame.env != self.suggest_env {
|
||||
self.switch_completion_environment(frame.env, frame.cwd);
|
||||
}
|
||||
if name == ARGV_VAR {
|
||||
let words: Vec<_> = line.split('\0').map(str::to_owned).collect();
|
||||
if let Some(env) =
|
||||
self.completion_context.submit_argv(&self.suggest_env, &self.cwd, &words)
|
||||
{
|
||||
self.switch_completion_environment(env, String::new());
|
||||
}
|
||||
} else {
|
||||
self.completion_submitted(line);
|
||||
}
|
||||
} else if name == SHELL_VAR {
|
||||
if let Some((env, cwd)) =
|
||||
self.completion_context.prompt(&self.suggest_env, &self.cwd, value)
|
||||
{
|
||||
if env != self.suggest_env {
|
||||
self.switch_completion_environment(env, cwd);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn switch_completion_environment(&mut self, env: SuggestEnv, cwd: String) {
|
||||
self.suggest_env = env;
|
||||
self.cwd = cwd;
|
||||
self.pending_remote_dir = None;
|
||||
self.clear_completion_hints();
|
||||
self.completion_suppressed_line = None;
|
||||
self.screen_line.clear();
|
||||
self.line_buf.clear();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn initial_state(
|
||||
options: &nebula_terminal::tty::Options,
|
||||
cwd: String,
|
||||
) -> NebulaPaneState {
|
||||
let mut state = NebulaPaneState::default();
|
||||
state.cwd = cwd;
|
||||
state.suggest_env = options
|
||||
.shell
|
||||
.as_ref()
|
||||
.map(|shell| launch_environment(shell.program(), shell.args()))
|
||||
.unwrap_or_default();
|
||||
state
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn connection_environment(parent: &SuggestEnv, line: &str) -> Option<SuggestEnv> {
|
||||
let words = crate::ssh::command_words(line)?;
|
||||
connection_argv_environment(parent, &words)
|
||||
}
|
||||
|
||||
fn connection_argv_environment(parent: &SuggestEnv, words: &[String]) -> Option<SuggestEnv> {
|
||||
if crate::ssh::ssh_destination_words(words).is_some() {
|
||||
return Some(typed_environment(parent, words, false));
|
||||
}
|
||||
let program = words.first()?.rsplit(['/', '\\']).next()?;
|
||||
if crate::display::extract_program(program).as_deref() == Some("wsl") {
|
||||
return Some(typed_environment(parent, words, true));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn typed_environment(parent: &SuggestEnv, words: &[String], wsl: bool) -> SuggestEnv {
|
||||
// A destination alias is resolved in its parent environment. Keep both
|
||||
// that ancestry and connection options in the history identity. The typed
|
||||
// SSH route cannot be substituted with the outer pane's SFTP connection.
|
||||
let mut hash = Sha256::new();
|
||||
hash.update(format!("{:?}", parent.history_scope()));
|
||||
for word in words {
|
||||
hash.update((word.len() as u64).to_le_bytes());
|
||||
hash.update(word.as_bytes());
|
||||
}
|
||||
let digest = hash.finalize();
|
||||
let mut identity = String::from("typed:");
|
||||
for byte in digest {
|
||||
let _ = write!(identity, "{byte:02x}");
|
||||
}
|
||||
let scope = if wsl { HistoryScope::Wsl(identity) } else { HistoryScope::Ssh(identity) };
|
||||
SuggestEnv::Shell { scope }
|
||||
}
|
||||
|
||||
pub(crate) fn launch_environment(program: &str, args: &[String]) -> SuggestEnv {
|
||||
let program_name = program.rsplit(['/', '\\']).next().unwrap_or(program);
|
||||
match crate::display::extract_program(program_name).as_deref() {
|
||||
Some("wsl") => {
|
||||
let distro = crate::shell_detect::wsl_launch_distro(program, args)
|
||||
.map(str::to_owned)
|
||||
.or_else(crate::platform::shell::default_wsl_distro)
|
||||
.unwrap_or_default();
|
||||
SuggestEnv::Wsl { distro }
|
||||
},
|
||||
Some("ssh") => {
|
||||
let words: Vec<_> =
|
||||
std::iter::once(program.to_owned()).chain(args.iter().cloned()).collect();
|
||||
typed_environment(&SuggestEnv::Local, &words, false)
|
||||
},
|
||||
_ => SuggestEnv::Local,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,200 @@
|
||||
use super::*;
|
||||
|
||||
fn pane(env: SuggestEnv, token: &str) -> NebulaPaneState {
|
||||
let mut state = NebulaPaneState::default();
|
||||
state.suggest_env = env;
|
||||
state.cwd = "/parent".into();
|
||||
state.completion_shell_report(SHELL_VAR, token);
|
||||
state
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_ssh_never_uses_local_history_and_only_parent_identity_restores_it() {
|
||||
let mut state = pane(SuggestEnv::Local, "pwsh:parent");
|
||||
state.completion_submitted("ssh user@box");
|
||||
let remote = state.suggest_env.history_scope();
|
||||
assert!(matches!(remote, HistoryScope::Ssh(_)));
|
||||
// A plain fish shell keeps using its remote history until the enclosing
|
||||
// pwsh reports its prompt again. Individual remote commands do not pop it.
|
||||
state.completion_submitted("set -gx FISH_VAR value");
|
||||
state.completion_submitted("exit");
|
||||
assert_eq!(state.suggest_env.history_scope(), remote);
|
||||
state.completion_shell_report(SHELL_VAR, "pwsh:parent");
|
||||
assert_eq!(state.suggest_env.history_scope(), HistoryScope::Local);
|
||||
assert_eq!(state.cwd, "/parent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reported_nested_shells_restore_their_own_ancestors() {
|
||||
let mut state = pane(SuggestEnv::Local, "pwsh:parent");
|
||||
state.completion_submitted("pebrel ssh bastion");
|
||||
state.completion_shell_report(SHELL_VAR, "bash:bastion");
|
||||
let bastion = state.suggest_env.clone();
|
||||
assert!(matches!(bastion, SuggestEnv::Shell { .. }));
|
||||
assert!(!bastion.can_query_remote_paths());
|
||||
state.cwd = "/bastion".into();
|
||||
state.completion_submitted("ssh inner");
|
||||
state.completion_shell_report(SHELL_VAR, "zsh:inner");
|
||||
assert_ne!(state.suggest_env, bastion);
|
||||
state.completion_submitted("exit");
|
||||
assert_ne!(state.suggest_env, bastion);
|
||||
state.completion_shell_report(SHELL_VAR, "bash:bastion");
|
||||
assert_eq!(state.suggest_env, bastion);
|
||||
assert_eq!(state.cwd, "/bastion");
|
||||
state.completion_shell_report(SHELL_VAR, "pwsh:parent");
|
||||
assert_eq!(state.suggest_env, SuggestEnv::Local);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_or_cancelled_connections_restore_without_using_an_exit_status() {
|
||||
let root = SuggestEnv::Wsl { distro: "Debian".into() };
|
||||
let mut state = pane(root.clone(), "wsl|Debian|bash:parent");
|
||||
for command in ["ssh nonexistent.invalid", "ssh host", "wsl.exe -d Missing"] {
|
||||
state.completion_submitted(command);
|
||||
assert_ne!(state.suggest_env, root);
|
||||
state.completion_shell_report(SHELL_VAR, "wsl|Debian|bash:parent");
|
||||
assert_eq!(state.suggest_env, root);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transitions_drop_ghost_popup_suppression_mirrors_and_pending_directory() {
|
||||
let mut state = pane(SuggestEnv::Ssh { destination: "outer".into() }, "bash:outer");
|
||||
state.suggestion = "outer-path".into();
|
||||
state.pending_remote_dir = Some("/outer".into());
|
||||
state.completion_suppressed_line = Some("ls".into());
|
||||
state.line_buf = "ssh inner".into();
|
||||
state.screen_line = state.line_buf.clone();
|
||||
state.completion_items.push(crate::display::NebulaCompletionItem {
|
||||
label: "outer-path".into(),
|
||||
insert: "outer-path".into(),
|
||||
replace_chars: 0,
|
||||
kind: crate::display::NebulaCompletionKind::History,
|
||||
});
|
||||
state.completion_selected = Some(0);
|
||||
state.completion_submitted("ssh inner");
|
||||
assert!(state.suggestion.is_empty());
|
||||
assert!(state.completion_items.is_empty());
|
||||
assert!(state.completion_selected.is_none());
|
||||
assert!(state.pending_remote_dir.is_none());
|
||||
assert!(state.completion_suppressed_line.is_none());
|
||||
assert!(state.screen_line.is_empty());
|
||||
assert!(state.line_buf.is_empty());
|
||||
assert!(state.cwd.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_route_identity_includes_parent_and_connection_options() {
|
||||
fn scope(root: SuggestEnv, line: &str) -> HistoryScope {
|
||||
let mut state = pane(root, "parent");
|
||||
state.completion_submitted(line);
|
||||
state.completion_shell_report(SHELL_VAR, "child");
|
||||
state.suggest_env.history_scope()
|
||||
}
|
||||
let direct = scope(SuggestEnv::Local, "ssh alias");
|
||||
assert_ne!(direct, HistoryScope::Ssh("alias".into()));
|
||||
assert_ne!(direct, scope(SuggestEnv::Local, "ssh -p 2200 alias"));
|
||||
assert_ne!(direct, scope(SuggestEnv::Local, "ssh -F other-config alias"));
|
||||
assert_ne!(direct, scope(SuggestEnv::Ssh { destination: "bastion".into() }, "ssh alias"));
|
||||
assert_eq!(direct, scope(SuggestEnv::Local, "ssh alias"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepted_shell_text_catches_native_recall_and_does_not_duplicate_transition() {
|
||||
let mut state = pane(SuggestEnv::Local, "pwsh:parent");
|
||||
state.completion_submitted("");
|
||||
state.completion_shell_report(COMMAND_VAR, "pwsh:parent\nssh remembered-host");
|
||||
assert!(matches!(state.suggest_env, SuggestEnv::Shell { .. }));
|
||||
state.completion_shell_report(COMMAND_VAR, "pwsh:parent\nssh remembered-host");
|
||||
state.completion_shell_report(SHELL_VAR, "bash:remote");
|
||||
assert!(matches!(state.suggest_env, SuggestEnv::Shell { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_shell_on_the_same_host_keeps_that_hosts_history() {
|
||||
let mut state = pane(SuggestEnv::Local, "pwsh:parent");
|
||||
state.completion_shell_report(SHELL_VAR, "unexpected:child");
|
||||
assert_eq!(state.suggest_env.history_scope(), HistoryScope::Local);
|
||||
state.completion_shell_report(SHELL_VAR, "pwsh:parent");
|
||||
assert_eq!(state.suggest_env, SuggestEnv::Local);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execution_arguments_separate_variable_targets_and_restore_before_next_command() {
|
||||
let mut state = pane(SuggestEnv::Local, "pwsh:parent");
|
||||
state.completion_submitted("ssh $targetHost");
|
||||
state.completion_shell_report(ARGV_VAR, "pwsh:parent\nssh\0one.example");
|
||||
let first = state.suggest_env.clone();
|
||||
state.completion_shell_report(SHELL_VAR, "pwsh:parent");
|
||||
assert_eq!(state.suggest_env, SuggestEnv::Local);
|
||||
state.completion_submitted("ssh $targetHost");
|
||||
state.completion_shell_report(ARGV_VAR, "pwsh:parent\nssh\0two.example");
|
||||
assert_ne!(state.suggest_env, first);
|
||||
state.completion_shell_report(SHELL_VAR, "pwsh:parent");
|
||||
assert_eq!(state.suggest_env, SuggestEnv::Local);
|
||||
// An actual argument may contain shell operators as literal text.
|
||||
state.completion_shell_report(
|
||||
ARGV_VAR,
|
||||
"pwsh:parent\nssh\0-o\0ProxyCommand=ssh -W %h:%p jump\0one.example",
|
||||
);
|
||||
assert!(matches!(state.suggest_env, SuggestEnv::Shell { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_input_snapshot_does_not_disable_remote_history() {
|
||||
let mut state = pane(SuggestEnv::Local, "pwsh:parent");
|
||||
state.completion_submitted("ssh fish-host");
|
||||
let remote = state.suggest_env.clone();
|
||||
state.completion_submitted("");
|
||||
assert_eq!(state.suggest_env, remote);
|
||||
state.completion_shell_report(SHELL_VAR, "pwsh:parent");
|
||||
assert_eq!(state.suggest_env, SuggestEnv::Local);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_classifies_named_default_wsl_and_direct_ssh() {
|
||||
assert_eq!(launch_environment("pwsh.exe", &[]), SuggestEnv::Local);
|
||||
assert!(matches!(
|
||||
launch_environment(r"C:\Windows\System32\wsl.exe", &[]),
|
||||
SuggestEnv::Wsl { .. }
|
||||
));
|
||||
assert_eq!(
|
||||
launch_environment("wsl.exe", &["-d".into(), "Ubuntu".into()]),
|
||||
SuggestEnv::Wsl { distro: "Ubuntu".into() }
|
||||
);
|
||||
assert!(matches!(launch_environment("ssh.exe", &["host".into()]), SuggestEnv::Shell { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unnamed_wsl_uses_the_distro_reported_by_its_shell() {
|
||||
let state = pane(SuggestEnv::Wsl { distro: String::new() }, "wsl|Ubuntu|bash:guest");
|
||||
assert_eq!(state.suggest_env, SuggestEnv::Wsl { distro: "Ubuntu".into() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_an_actual_ssh_or_wsl_invocation_changes_history_scope() {
|
||||
for line in [
|
||||
"ssh -p 2200 user@host",
|
||||
"SSH.EXE host",
|
||||
"pebrel ssh host",
|
||||
"wsl.exe",
|
||||
r#"& 'C:\Program Files\OpenSSH\ssh.exe' host"#,
|
||||
r#"& 'C:\Windows\System32\wsl.exe' -d Ubuntu"#,
|
||||
] {
|
||||
assert!(connection_environment(&SuggestEnv::Local, line).is_some(), "{line}");
|
||||
}
|
||||
for line in [
|
||||
"",
|
||||
"git status",
|
||||
"echo ssh host",
|
||||
"echo 'ssh host; exit'",
|
||||
"ssh -N host",
|
||||
"ssh host ls",
|
||||
"echo ready; ssh host",
|
||||
"sudo ls",
|
||||
"fish",
|
||||
"exit",
|
||||
] {
|
||||
assert!(connection_environment(&SuggestEnv::Local, line).is_none(), "{line}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! Legacy completion adapter; ownership rules live in completion_context.
|
||||
|
||||
use super::{Display, NebulaPaneState, nebula_clear_line, nebula_debug_log, suggest_engine};
|
||||
|
||||
impl Display {
|
||||
/// Commit the current line to history (on Enter) and reset the buffer.
|
||||
///
|
||||
/// `screen_line` (the input read off the grid, i.e. what the shell's own
|
||||
/// editor really contained) wins over the keystroke-reconstructed
|
||||
/// `line_buf`: the latter desyncs on cursor motion / completion / history
|
||||
/// recall and used to commit spliced garbage like "laudeclaude", which the
|
||||
/// hint would then resurface as a command the user never typed.
|
||||
pub fn nebula_commit_line(&mut self, state: &mut NebulaPaneState) {
|
||||
// On Windows the grid read is the only source that sees tab
|
||||
// completions; when it failed (no prompt arrow — cmd/ssh/REPL — or a
|
||||
// mid-line edit) the keystroke buffer likely holds spliced garbage,
|
||||
// and recording that would resurface it forever as a bogus ghost hint
|
||||
// (truncated CJK paths were the visible symptom). Better no history
|
||||
// entry than a corrupted one.
|
||||
#[cfg(windows)]
|
||||
let line = state.screen_line.trim().to_owned();
|
||||
#[cfg(not(windows))]
|
||||
let line = if state.screen_line.trim().is_empty() {
|
||||
state.line_buf.trim()
|
||||
} else {
|
||||
state.screen_line.trim()
|
||||
}
|
||||
.to_owned();
|
||||
nebula_debug_log(format!(
|
||||
"input_commit cwd={:?} line={line:?} line_buf={:?} screen_line={:?}",
|
||||
state.cwd, state.line_buf, state.screen_line
|
||||
));
|
||||
let committed =
|
||||
if line.is_empty() { state.line_buf.trim().to_owned() } else { line.clone() };
|
||||
if line.is_empty() {
|
||||
state.completion_submitted(&state.line_buf.clone());
|
||||
} else {
|
||||
state.record_completion_command(&mut self.nebula_history, &line);
|
||||
}
|
||||
// Kept for CommandStart (OSC 133;C): by the time it arrives from the
|
||||
// PTY these buffers are already cleared, so the program identity for
|
||||
// the tab icon has to be captured here. Fall back to the keystroke
|
||||
// buffer so the icon still resolves when the grid read failed. Agent
|
||||
// parsing also understands package runners such as npx/uvx.
|
||||
state.last_committed = committed;
|
||||
if let Some(agent) = crate::ai_agents::AgentKind::parse_command(&state.last_committed) {
|
||||
state.running_program = Some(agent.slug().to_owned());
|
||||
state.command_started = Some(std::time::Instant::now());
|
||||
state.agent_status = crate::ai_agents::AgentStatus::Working;
|
||||
state.agent_status_source = crate::ai_agents::AgentStatusSource::Process;
|
||||
state.agent_status_rule = None;
|
||||
state.agent_hook_seen = false;
|
||||
state.idle_screen_streak = 0;
|
||||
state.awaiting_input = false;
|
||||
state.finished_unseen = false;
|
||||
state.needs_attention = false;
|
||||
}
|
||||
nebula_clear_line(state);
|
||||
}
|
||||
|
||||
/// Feed the shared directory model from an authoritative shell report.
|
||||
pub fn nebula_report_cwd(&self, state: &mut NebulaPaneState, cwd: &str) -> bool {
|
||||
if state.cwd == cwd {
|
||||
return false;
|
||||
}
|
||||
state.cwd = cwd.to_owned();
|
||||
if state.suggest_env.is_this_machine() {
|
||||
self.directory_history.record(cwd);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Recompute the inline ghost-text suggestion. `line_override` carries the
|
||||
/// grid-read input on Windows (the authoritative screen truth); when `None`
|
||||
/// the keystroke-tracked `line_buf` is used (other platforms). A whole
|
||||
/// previous command sharing the prefix wins (fish-style history hint);
|
||||
/// otherwise the final token gets path completion against the shell-reported
|
||||
/// cwd. Cached on `cwd\0buffer` so disk is only touched when the line
|
||||
/// changes — not every frame.
|
||||
pub(super) fn nebula_update_suggestion(
|
||||
&mut self,
|
||||
state: &mut NebulaPaneState,
|
||||
line_override: Option<String>,
|
||||
) {
|
||||
suggest_engine::suggest_update(
|
||||
&suggest_engine::SuggestSources {
|
||||
history: &self.nebula_history,
|
||||
directories: &self.directory_history,
|
||||
commands: &self.nebula_commands,
|
||||
enabled: self.nebula_ghost_enabled,
|
||||
style: self.nebula_completion_style,
|
||||
},
|
||||
state,
|
||||
line_override,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -249,7 +249,7 @@ fn shell_prompt_restored(expected_prompt: &str, current_line: &str, env: &Sugges
|
||||
return true;
|
||||
}
|
||||
|
||||
matches!(env, SuggestEnv::Wsl { .. } | SuggestEnv::Ssh { .. })
|
||||
!env.is_this_machine()
|
||||
&& remote_prompt_anchor(expected, marker)
|
||||
.zip(remote_prompt_anchor(current, marker))
|
||||
.is_some_and(|(expected, current)| expected == current)
|
||||
@@ -307,7 +307,7 @@ fn likely_prompt(prompt: &str, marker: char, env: &SuggestEnv) -> bool {
|
||||
|| head.get(1..2) == Some(":")
|
||||
|| head.starts_with("PS ")
|
||||
},
|
||||
SuggestEnv::Wsl { .. } | SuggestEnv::Ssh { .. } => {
|
||||
SuggestEnv::Wsl { .. } | SuggestEnv::Ssh { .. } | SuggestEnv::Shell { .. } => {
|
||||
head.contains(['@', ':', '/', '~', ']', ')'])
|
||||
},
|
||||
}
|
||||
@@ -341,6 +341,27 @@ mod tests {
|
||||
assert!(!raw_grid_line_is_readable(Line(30), topmost, bottommost));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_text_after_cursor_yields_completion_until_accepted() {
|
||||
use nebula_terminal::event::VoidListener;
|
||||
use nebula_terminal::grid::Grid;
|
||||
|
||||
let size = Grid::<Cell>::new(2, 80, 0);
|
||||
let mut terminal = Term::new(Default::default(), &size, VoidListener);
|
||||
for (column, c) in "❯ echo native_hint".chars().enumerate() {
|
||||
terminal.grid_mut()[Line(0)][Column(column)].c = c;
|
||||
}
|
||||
// The shell owns the visible suffix, so Pebrel must not overlay it or
|
||||
// treat it as an already accepted command. Color is user-configurable.
|
||||
let cursor = Point::new(Line(0), Column(7));
|
||||
assert_eq!(raw_grid_logical_line(&terminal, cursor), None);
|
||||
let accepted = Point::new(Line(0), Column(18));
|
||||
assert_eq!(
|
||||
raw_grid_logical_line(&terminal, accepted).as_deref(),
|
||||
Some("❯ echo native_hint")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_prompt_reconciles_the_typed_tail() {
|
||||
let env = SuggestEnv::Local;
|
||||
@@ -425,9 +446,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn remote_prompt_restore_allows_only_same_host_dynamic_cwd() {
|
||||
let env = SuggestEnv::Wsl { distro: "Ubuntu".to_owned() };
|
||||
assert!(shell_prompt_restored("dev@box:~$", "dev@box:/work$ ", &env));
|
||||
assert!(!shell_prompt_restored("dev@box:~$", "dev@other:/work$ ", &env));
|
||||
assert!(!shell_prompt_restored("dev@box:~$", "build:/work$ ", &env));
|
||||
for env in [
|
||||
SuggestEnv::Wsl { distro: "Ubuntu".to_owned() },
|
||||
SuggestEnv::Shell {
|
||||
scope: crate::nebula_history::HistoryScope::Ssh("typed:test".into()),
|
||||
},
|
||||
] {
|
||||
assert!(shell_prompt_restored("dev@box:~$", "dev@box:/work$ ", &env));
|
||||
assert!(!shell_prompt_restored("dev@box:~$", "dev@other:/work$ ", &env));
|
||||
assert!(!shell_prompt_restored("dev@box:~$", "build:/work$ ", &env));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,7 @@ use crate::string::{ShortenDirection, StrShortener};
|
||||
mod background_color_model;
|
||||
pub mod color;
|
||||
mod command_completion;
|
||||
mod completion;
|
||||
pub mod content;
|
||||
pub mod cursor;
|
||||
pub mod hint;
|
||||
@@ -9836,86 +9837,6 @@ impl Display {
|
||||
dirty
|
||||
}
|
||||
|
||||
/// Commit the current line to history (on Enter) and reset the buffer.
|
||||
///
|
||||
/// `screen_line` (the input read off the grid, i.e. what the shell's own
|
||||
/// editor really contained) wins over the keystroke-reconstructed
|
||||
/// `line_buf`: the latter desyncs on cursor motion / completion / history
|
||||
/// recall and used to commit spliced garbage like "laudeclaude", which the
|
||||
/// hint would then resurface as a command the user never typed.
|
||||
pub fn nebula_commit_line(&mut self, state: &mut NebulaPaneState) {
|
||||
// On Windows the grid read is the only source that sees tab
|
||||
// completions; when it failed (no prompt arrow — cmd/ssh/REPL — or a
|
||||
// mid-line edit) the keystroke buffer likely holds spliced garbage,
|
||||
// and recording that would resurface it forever as a bogus ghost hint
|
||||
// (truncated CJK paths were the visible symptom). Better no history
|
||||
// entry than a corrupted one.
|
||||
#[cfg(windows)]
|
||||
let line = state.screen_line.trim().to_owned();
|
||||
#[cfg(not(windows))]
|
||||
let line = if state.screen_line.trim().is_empty() {
|
||||
state.line_buf.trim()
|
||||
} else {
|
||||
state.screen_line.trim()
|
||||
}
|
||||
.to_owned();
|
||||
nebula_debug_log(format!(
|
||||
"input_commit cwd={:?} line={line:?} line_buf={:?} screen_line={:?}",
|
||||
state.cwd, state.line_buf, state.screen_line
|
||||
));
|
||||
self.nebula_history.record(&state.suggest_env.history_scope(), &line, &state.cwd);
|
||||
// Kept for CommandStart (OSC 133;C): by the time it arrives from the
|
||||
// PTY these buffers are already cleared, so the program identity for
|
||||
// the tab icon has to be captured here. Fall back to the keystroke
|
||||
// buffer so the icon still resolves when the grid read failed. Agent
|
||||
// parsing also understands package runners such as npx/uvx.
|
||||
state.last_committed =
|
||||
if line.is_empty() { state.line_buf.trim().to_owned() } else { line };
|
||||
if let Some(agent) = crate::ai_agents::AgentKind::parse_command(&state.last_committed) {
|
||||
state.running_program = Some(agent.slug().to_owned());
|
||||
state.command_started = Some(std::time::Instant::now());
|
||||
state.agent_status = crate::ai_agents::AgentStatus::Working;
|
||||
state.agent_status_source = crate::ai_agents::AgentStatusSource::Process;
|
||||
state.agent_status_rule = None;
|
||||
state.agent_hook_seen = false;
|
||||
state.idle_screen_streak = 0;
|
||||
state.awaiting_input = false;
|
||||
state.finished_unseen = false;
|
||||
state.needs_attention = false;
|
||||
}
|
||||
nebula_clear_line(state);
|
||||
}
|
||||
|
||||
/// Feed the shared directory model from an authoritative shell report.
|
||||
pub fn nebula_record_directory(&self, cwd: &str) {
|
||||
self.directory_history.record(cwd);
|
||||
}
|
||||
|
||||
/// Recompute the inline ghost-text suggestion. `line_override` carries the
|
||||
/// grid-read input on Windows (the authoritative screen truth); when `None`
|
||||
/// the keystroke-tracked `line_buf` is used (other platforms). A whole
|
||||
/// previous command sharing the prefix wins (fish-style history hint);
|
||||
/// otherwise the final token gets path completion against the shell-reported
|
||||
/// cwd. Cached on `cwd\0buffer` so disk is only touched when the line
|
||||
/// changes — not every frame.
|
||||
fn nebula_update_suggestion(
|
||||
&mut self,
|
||||
state: &mut NebulaPaneState,
|
||||
line_override: Option<String>,
|
||||
) {
|
||||
suggest_engine::suggest_update(
|
||||
&suggest_engine::SuggestSources {
|
||||
history: &self.nebula_history,
|
||||
directories: &self.directory_history,
|
||||
commands: &self.nebula_commands,
|
||||
enabled: self.nebula_ghost_enabled,
|
||||
style: self.nebula_completion_style,
|
||||
},
|
||||
state,
|
||||
line_override,
|
||||
);
|
||||
}
|
||||
|
||||
/// Render the popup completion list on the terminal cell grid: one padded
|
||||
/// row per candidate directly below the cursor (above when the prompt sits
|
||||
/// near the bottom), the selected row on the theme accent. Cell-grid
|
||||
|
||||
@@ -216,6 +216,7 @@ pub struct NebulaPaneState {
|
||||
/// 改写。`cwd` 只说"在哪个目录",这个说"在哪台机器"——两者都对了补齐才
|
||||
/// 补得对,见 [`crate::display::suggest_engine::SuggestEnv`]。
|
||||
pub suggest_env: crate::display::suggest_engine::SuggestEnv,
|
||||
pub(crate) completion_context: crate::completion_context::CompletionContext,
|
||||
/// 上一次重算发现"这个来宾/远端目录还没缓存",壳该去异步拉一次。
|
||||
///
|
||||
/// 补齐本身绝不做 IO(WSL 冷启动可达 7.5 秒),所以它只能把需求登记在
|
||||
|
||||
@@ -202,6 +202,9 @@ pub enum SuggestEnv {
|
||||
/// SSH tab:文件系统只能经 SFTP 往返,命令集是远端的。`destination` 进
|
||||
/// 缓存键——同一条 `/home/kud` 在两台远端上是两个不同的目录。
|
||||
Ssh { destination: String },
|
||||
/// A shell reached through a typed command. History has its own route;
|
||||
/// there is no verified local WSL/SFTP channel for filesystem queries.
|
||||
Shell { scope: crate::nebula_history::HistoryScope },
|
||||
}
|
||||
|
||||
impl SuggestEnv {
|
||||
@@ -210,13 +213,25 @@ impl SuggestEnv {
|
||||
matches!(self, Self::Local)
|
||||
}
|
||||
|
||||
pub(crate) fn can_query_remote_paths(&self) -> bool {
|
||||
matches!(self, Self::Ssh { .. })
|
||||
|| matches!(self, Self::Wsl { distro } if !distro.is_empty())
|
||||
}
|
||||
|
||||
pub(crate) fn history_scope(&self) -> crate::nebula_history::HistoryScope {
|
||||
match self {
|
||||
Self::Local => crate::nebula_history::HistoryScope::Local,
|
||||
Self::Wsl { distro } => crate::nebula_history::HistoryScope::Wsl(distro.clone()),
|
||||
Self::Wsl { distro } => {
|
||||
crate::nebula_history::HistoryScope::Wsl(if distro.is_empty() {
|
||||
"<default>".to_owned()
|
||||
} else {
|
||||
distro.clone()
|
||||
})
|
||||
},
|
||||
Self::Ssh { destination } => {
|
||||
crate::nebula_history::HistoryScope::Ssh(destination.clone())
|
||||
},
|
||||
Self::Shell { scope } => scope.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -245,7 +260,10 @@ pub(crate) fn suggest_update(
|
||||
// 远端目录是异步拉回来的,那一刻 cwd 与行都没变——少了这个代际,拉到的
|
||||
// 条目要等用户再多打一个字符才会显形。
|
||||
let remote_generation = crate::remote_dirs::generation();
|
||||
let key = format!("{}\u{0}{line}\u{0}{command_generation}\u{0}{remote_generation}", state.cwd);
|
||||
let key = format!(
|
||||
"{:?}\0{}\0{line}\0{command_generation}\0{remote_generation}",
|
||||
state.suggest_env, state.cwd
|
||||
);
|
||||
if state.completion_suppressed_line.as_deref() == Some(line.as_str()) {
|
||||
state.suggestion_key = key;
|
||||
state.suggestion.clear();
|
||||
@@ -422,6 +440,9 @@ fn remote_path_matches(
|
||||
line: &str,
|
||||
token: &str,
|
||||
) -> Vec<(String, bool)> {
|
||||
if !state.suggest_env.can_query_remote_paths() {
|
||||
return Vec::new();
|
||||
}
|
||||
if nebula_is_command_position(line) && !token.contains('/') {
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -622,6 +643,77 @@ mod tests {
|
||||
use crate::directory_history::DirectoryHistory;
|
||||
use crate::nebula_history::NebulaHistory;
|
||||
|
||||
#[test]
|
||||
fn changing_environment_invalidates_an_identical_input_cache() {
|
||||
let fixture = Fixture::new();
|
||||
let mut state = NebulaPaneState::default();
|
||||
suggest_update(
|
||||
&fixture.sources(CompletionStyle::Inline),
|
||||
&mut state,
|
||||
Some("notepad".into()),
|
||||
);
|
||||
assert_eq!(state.suggestion, ".exe");
|
||||
state.suggest_env = SuggestEnv::Ssh { destination: "cache-test".into() };
|
||||
suggest_update(
|
||||
&fixture.sources(CompletionStyle::Inline),
|
||||
&mut state,
|
||||
Some("notepad".into()),
|
||||
);
|
||||
assert!(state.suggestion.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_outer_directory_results_cannot_complete_an_inner_shell() {
|
||||
let fixture = Fixture::new();
|
||||
let outer = SuggestEnv::Ssh { destination: "late-directory-test".into() };
|
||||
let mut state = NebulaPaneState {
|
||||
suggest_env: outer.clone(),
|
||||
cwd: "/scope-test".into(),
|
||||
..Default::default()
|
||||
};
|
||||
state.completion_shell_report(crate::completion_context::SHELL_VAR, "outer");
|
||||
suggest_update(
|
||||
&fixture.sources(CompletionStyle::Inline),
|
||||
&mut state,
|
||||
Some("cat ./outer-".into()),
|
||||
);
|
||||
let dir = state.pending_remote_dir.clone().expect("missing outer directory");
|
||||
assert!(crate::remote_dirs::begin_fetch(&outer, &dir));
|
||||
state.completion_submitted("ssh inner");
|
||||
state.completion_shell_report(crate::completion_context::SHELL_VAR, "inner");
|
||||
crate::remote_dirs::finish_fetch(
|
||||
&outer,
|
||||
&dir,
|
||||
Some(vec![crate::remote_dirs::RemoteEntry {
|
||||
name: "outer-only-file".into(),
|
||||
is_dir: false,
|
||||
}]),
|
||||
);
|
||||
for style in [CompletionStyle::Inline, CompletionStyle::Popup] {
|
||||
suggest_update(&fixture.sources(style), &mut state, Some("cat ./outer-".into()));
|
||||
assert!(state.suggestion.is_empty());
|
||||
assert!(state.completion_items.is_empty());
|
||||
assert!(state.pending_remote_dir.is_none());
|
||||
}
|
||||
state.completion_shell_report(crate::completion_context::SHELL_VAR, "outer");
|
||||
suggest_update(
|
||||
&fixture.sources(CompletionStyle::Inline),
|
||||
&mut state,
|
||||
Some("cat ./outer-".into()),
|
||||
);
|
||||
assert_eq!(state.suggestion, "only-file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unnamed_wsl_keeps_guest_commands_and_never_queries_the_host_filesystem() {
|
||||
let fixture = Fixture::new();
|
||||
let env = SuggestEnv::Wsl { distro: String::new() };
|
||||
assert!(fixture.ghost(env.clone(), "", "notepad").is_empty());
|
||||
assert!(!fixture.ghost(env.clone(), "", "gre").is_empty());
|
||||
assert!(!crate::remote_dirs::begin_fetch(&env, "/"));
|
||||
assert_ne!(env.history_scope(), crate::nebula_history::HistoryScope::Local);
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
history: NebulaHistory,
|
||||
directories: DirectoryHistory,
|
||||
|
||||
@@ -1380,7 +1380,7 @@ pub(crate) fn draw_overlays(
|
||||
bottom: plan.clip_bottom,
|
||||
};
|
||||
if renderer
|
||||
.draw_math(size, layout, plan.origin_x, plan.baseline_y, plan.foreground, clip)
|
||||
.draw_math(size, &layout, plan.origin_x, plan.baseline_y, plan.foreground, clip)
|
||||
.is_err()
|
||||
{
|
||||
renderer.draw_cells(size, glyph_cache, overlay.fallback.iter().cloned());
|
||||
|
||||
@@ -2845,10 +2845,7 @@ impl input::Processor<EventProxy, ActionContext<'_, Notifier, EventProxy>> {
|
||||
if let Some(rest) = title.strip_prefix("NEBULA|") {
|
||||
let mut parts = rest.splitn(3, '|');
|
||||
let cwd = parts.next().unwrap_or("").to_owned();
|
||||
if self.ctx.nebula_state.cwd != cwd {
|
||||
self.ctx.nebula_state.cwd.clone_from(&cwd);
|
||||
self.ctx.display.nebula_record_directory(&cwd);
|
||||
}
|
||||
self.ctx.display.nebula_report_cwd(self.ctx.nebula_state, &cwd);
|
||||
self.ctx.nebula_state.branch = parts.next().unwrap_or("").to_owned();
|
||||
if let Some(program) = parts.next() {
|
||||
self.ctx.nebula_state.running_program = if program.is_empty() {
|
||||
@@ -2895,9 +2892,7 @@ impl input::Processor<EventProxy, ActionContext<'_, Notifier, EventProxy>> {
|
||||
// Standard OSC 7 / 9;9 directory report. Update cwd only,
|
||||
// leaving any branch captured from a `NEBULA|cwd|branch`
|
||||
// title intact, so the two channels coexist.
|
||||
if self.ctx.nebula_state.cwd != cwd {
|
||||
self.ctx.nebula_state.cwd.clone_from(&cwd);
|
||||
self.ctx.display.nebula_record_directory(&cwd);
|
||||
if self.ctx.display.nebula_report_cwd(self.ctx.nebula_state, &cwd) {
|
||||
*self.ctx.dirty = true;
|
||||
}
|
||||
},
|
||||
@@ -3060,8 +3055,7 @@ impl input::Processor<EventProxy, ActionContext<'_, Notifier, EventProxy>> {
|
||||
}
|
||||
},
|
||||
TerminalEvent::UserVar { name, value } => {
|
||||
// `nebula_ai_query`(`#` 自然语言转命令)是阶段二的
|
||||
// 消费者;通道先贯通,其余变量目前无人认领。
|
||||
self.ctx.nebula_state.completion_shell_report(&name, &value);
|
||||
if name == "nebula_ai_query" {
|
||||
info!(
|
||||
"assistant: query channel received ({} chars)",
|
||||
|
||||
@@ -74,6 +74,8 @@ pub struct Settings {
|
||||
pub cursor_blink: Option<bool>,
|
||||
/// 选区完成即复制(旧壳 `copy_on_select` 设置)。
|
||||
pub copy_on_select: bool,
|
||||
pub scrollback_lines: usize,
|
||||
pub scroll_speed: f32,
|
||||
/// Pointer handlers and split rendering only read these cached preferences.
|
||||
pub focus_follows_mouse: bool,
|
||||
pub dim_inactive_panes: bool,
|
||||
@@ -266,6 +268,8 @@ impl Settings {
|
||||
}),
|
||||
cursor_blink: runtime.cursor_blink,
|
||||
copy_on_select: runtime.copy_on_select,
|
||||
scrollback_lines: runtime.scrollback_lines,
|
||||
scroll_speed: runtime.scroll_speed,
|
||||
focus_follows_mouse: runtime
|
||||
.focus_follows_mouse
|
||||
.unwrap_or(raw.mouse.focus_follows_mouse),
|
||||
@@ -316,6 +320,7 @@ impl Settings {
|
||||
/// 引擎 Term 的启动配置(默认光标形状/闪烁来自运行时设置)。
|
||||
pub fn term_config(&self) -> nebula_terminal::term::Config {
|
||||
let mut config = nebula_terminal::term::Config::default();
|
||||
config.scrolling_history = self.scrollback_lines;
|
||||
if let Some(shape) = self.cursor_shape {
|
||||
config.default_cursor_style.shape = shape;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ mod design;
|
||||
mod font_picker;
|
||||
mod providers;
|
||||
mod reset;
|
||||
mod scrolling;
|
||||
mod search_header;
|
||||
mod setting_help;
|
||||
mod theme_picker;
|
||||
@@ -136,6 +137,8 @@ pub struct SettingsPane {
|
||||
pub(super) theme_foreground_picker: theme_foreground::ThemeForegroundState,
|
||||
opacity_slider: Entity<SliderState>,
|
||||
wallpaper_opacity_slider: Entity<SliderState>,
|
||||
scroll_speed_slider: Entity<SliderState>,
|
||||
scroll_speed_focus: FocusHandle,
|
||||
pub(super) proxy_url_input: Entity<InputState>,
|
||||
pub(super) proxy_protocol_select: SharedSelect,
|
||||
pub(super) proxy_test_seq: u64,
|
||||
@@ -669,6 +672,7 @@ impl SettingsPane {
|
||||
// sk.accent)。闭框/背景都不带文字色,包一层就能继承下去;右侧
|
||||
// chevron 在组件内自带 muted,不会被染色。
|
||||
let control = div()
|
||||
.debug_selector(move || format!("settings-select-{key}"))
|
||||
.w(px(SETTINGS_SELECT_WIDTH))
|
||||
.text_color(cx.theme().link)
|
||||
.children(select.map(|state| Select::new(&state)));
|
||||
@@ -748,6 +752,10 @@ impl SettingsPane {
|
||||
"new_tab_position" => pick!(new_tab_position),
|
||||
"windowing_behavior" => pick!(windowing_behavior),
|
||||
"cell_width_mode" => pick!(cell_width_mode),
|
||||
"scrollback_lines" => Some((
|
||||
cur.scrollback_lines != def.scrollback_lines,
|
||||
def.scrollback_lines.to_string(),
|
||||
)),
|
||||
"vcs_display" => pick!(vcs_display),
|
||||
"bell" => pick!(bell),
|
||||
"blur" => pick!(blur),
|
||||
@@ -801,6 +809,10 @@ impl SettingsPane {
|
||||
desc,
|
||||
dirty,
|
||||
move |this, window, cx| {
|
||||
if key == "scrollback_lines" {
|
||||
this.commit_scrollback_lines(&factory, window, cx);
|
||||
return;
|
||||
}
|
||||
this.persist(&[(key, factory.clone())], cx);
|
||||
// 开关行读 `runtime`,notify 就够;下拉框自己存索引,
|
||||
// 必须显式拉回,否则撤销只改了值不改显示。
|
||||
|
||||
@@ -132,6 +132,13 @@ impl SettingsPane {
|
||||
help("cell_width_mode", language),
|
||||
cx,
|
||||
))
|
||||
.child(self.select_row(
|
||||
"scrollback_lines",
|
||||
language.text(crate::i18n::Message::SettingsScrollingHistory),
|
||||
language.text(crate::i18n::Message::SettingsScrollingHistoryDescription),
|
||||
cx,
|
||||
))
|
||||
.child(self.scroll_speed_row(cx))
|
||||
.child(self.switch_row(
|
||||
"fetch",
|
||||
language.pick("启动欢迎信息", "Startup system information"),
|
||||
|
||||
@@ -32,7 +32,11 @@ impl SettingsPane {
|
||||
if let SelectEvent::Confirm(Some(_)) = event {
|
||||
let row = entity.read(cx).selected_index(cx).map(|path| path.row);
|
||||
if let Some(value) = row.and_then(|row| values.get(row)) {
|
||||
this.persist(&[(key, (*value).to_string())], cx);
|
||||
if key == "scrollback_lines" {
|
||||
this.commit_scrollback_lines(value, window, cx);
|
||||
} else {
|
||||
this.persist(&[(key, (*value).to_string())], cx);
|
||||
}
|
||||
if key == "language" {
|
||||
this.refresh_localized_controls(window, cx);
|
||||
cx.refresh_windows();
|
||||
@@ -120,6 +124,13 @@ impl SettingsPane {
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
add_select(
|
||||
"scrollback_lines",
|
||||
nebula_settings::SCROLLBACK_VALUES,
|
||||
&runtime.scrollback_lines.to_string(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
// 文案照抄旧壳 `accept_label` / `completion_style_label`。
|
||||
add_select(
|
||||
"bell",
|
||||
@@ -270,6 +281,9 @@ impl SettingsPane {
|
||||
let opacity_slider = cx.new(|_| {
|
||||
SliderState::new().min(0.00).max(1.00).step(0.05).default_value(runtime.opacity)
|
||||
});
|
||||
let scroll_speed_slider =
|
||||
Self::create_scroll_speed_slider(&runtime, window, cx, &mut subscriptions);
|
||||
let scroll_speed_focus = cx.focus_handle().tab_stop(true);
|
||||
subscriptions.push(cx.subscribe(&opacity_slider, |this, _, event: &SliderEvent, cx| {
|
||||
if let SliderEvent::Change(value) = event {
|
||||
this.set_opacity(value.start(), cx);
|
||||
@@ -568,6 +582,8 @@ impl SettingsPane {
|
||||
theme_foreground_picker: theme_foreground::ThemeForegroundState::new(cx),
|
||||
opacity_slider,
|
||||
wallpaper_opacity_slider,
|
||||
scroll_speed_slider,
|
||||
scroll_speed_focus,
|
||||
proxy_url_input,
|
||||
proxy_protocol_select,
|
||||
proxy_test_seq: 0,
|
||||
|
||||
@@ -7,6 +7,15 @@ pub(super) fn localized_select_labels(
|
||||
values: &[&'static str],
|
||||
language: crate::display::UiLanguage,
|
||||
) -> Vec<SharedString> {
|
||||
if key == "scrollback_lines" {
|
||||
return values
|
||||
.iter()
|
||||
.map(|value| {
|
||||
let number = value.parse::<usize>().expect("validated scrollback choice");
|
||||
format!("{},000", number / 1_000).into()
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
let labels: Vec<&'static str> = match key {
|
||||
"language" => nebula_settings::LanguagePref::ALL
|
||||
.iter()
|
||||
|
||||
@@ -27,7 +27,7 @@ pub(super) const SECTION_IDS: [&str; 10] = [
|
||||
/// section that owns the control instead of merely filtering the current page.
|
||||
pub(super) const SECTION_SEARCH_TERMS: [&str; 10] = [
|
||||
"application app 应用 update 更新 version 版本 github support 支持",
|
||||
"appearance 外观 theme 主题 custom 自定义 template 模板 import 导入 export 导出 font 字体 opacity 透明度 background 背景 cursor 光标 icon 图标 dim inactive panes 调暗非活动窗格 分屏变暗",
|
||||
"appearance 外观 theme 主题 custom 自定义 template 模板 import 导入 export 导出 font 字体 opacity 透明度 background 背景 cursor 光标 icon 图标 dim inactive panes 调暗非活动窗格 分屏变暗 scrollback scrolling speed history 回滚 滚动 速度 历史",
|
||||
"profiles 配置文件 shell terminal 终端 completion 补全 startup 启动 ai message notifications toast alerts bell 提醒 通知 弹窗 消息 右下角 ai消息通知 ai 消息通知 ai消息弹窗 ai 消息弹窗 铃声",
|
||||
"providers provider ai 供应商 模型 api",
|
||||
"ssh host 主机 remote 远程 connection 连接",
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
use super::*;
|
||||
use crate::gpui_shell::config::Settings;
|
||||
use crate::i18n::Message;
|
||||
use nebula_settings::{
|
||||
MAX_SCROLL_SPEED, MIN_SCROLL_SPEED, SCROLL_SPEED_STEP, normalize_scroll_speed,
|
||||
};
|
||||
|
||||
impl SettingsPane {
|
||||
pub(super) fn commit_scrollback_lines(
|
||||
&mut self,
|
||||
value: &str,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if let Err(error) = self.try_persist(&[("scrollback_lines", value.to_owned())], cx) {
|
||||
let language = crate::gpui_shell::config::ui_language(cx);
|
||||
crate::gpui_shell::toast::toast(
|
||||
window,
|
||||
cx,
|
||||
crate::gpui_shell::toast::ToastKind::Warning,
|
||||
language.format(Message::SettingsSaveFailed, &[("error", &error.to_string())]),
|
||||
);
|
||||
}
|
||||
self.sync_select(
|
||||
"scrollback_lines",
|
||||
&self.runtime.scrollback_lines.to_string(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn create_scroll_speed_slider(
|
||||
runtime: &RuntimeSettings,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
subscriptions: &mut Vec<Subscription>,
|
||||
) -> Entity<SliderState> {
|
||||
let slider = cx.new(|_| {
|
||||
SliderState::new()
|
||||
.min(MIN_SCROLL_SPEED)
|
||||
.max(MAX_SCROLL_SPEED)
|
||||
.step(SCROLL_SPEED_STEP)
|
||||
.default_value(runtime.scroll_speed)
|
||||
});
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&slider,
|
||||
window,
|
||||
|this, _, event: &SliderEvent, window, cx| match event {
|
||||
SliderEvent::Change(value) => this.preview_scroll_speed(value.start(), cx),
|
||||
SliderEvent::Release(value) => this.commit_scroll_speed(value.start(), window, cx),
|
||||
},
|
||||
));
|
||||
// Accessibility increment/decrement uses SliderState::set_value instead
|
||||
// of emitting Change/Release. Mouse and keyboard previews already match
|
||||
// runtime, so only these otherwise-unhandled value changes are saved here.
|
||||
subscriptions.push(cx.observe_in(&slider, window, |this, slider, window, cx| {
|
||||
let value = slider.read(cx).value().start();
|
||||
if (value - this.runtime.scroll_speed).abs() > f32::EPSILON {
|
||||
this.preview_scroll_speed(value, cx);
|
||||
this.commit_scroll_speed(value, window, cx);
|
||||
}
|
||||
}));
|
||||
slider
|
||||
}
|
||||
|
||||
fn preview_scroll_speed(&mut self, value: f32, cx: &mut Context<Self>) {
|
||||
let speed = normalize_scroll_speed(value);
|
||||
self.runtime.scroll_speed = speed;
|
||||
cx.global_mut::<Settings>().scroll_speed = speed;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn commit_scroll_speed(&mut self, value: f32, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let speed = normalize_scroll_speed(value);
|
||||
if let Err(error) = self.try_persist(&[("scroll_speed", format!("{speed:.2}"))], cx) {
|
||||
let saved = RuntimeSettings::load().scroll_speed;
|
||||
self.preview_scroll_speed(saved, cx);
|
||||
self.scroll_speed_slider.update(cx, |state, cx| state.set_value(saved, window, cx));
|
||||
let language = crate::gpui_shell::config::ui_language(cx);
|
||||
crate::gpui_shell::toast::toast(
|
||||
window,
|
||||
cx,
|
||||
crate::gpui_shell::toast::ToastKind::Warning,
|
||||
language.format(Message::SettingsSaveFailed, &[("error", &error.to_string())]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn scroll_speed_row(&self, cx: &Context<Self>) -> impl IntoElement {
|
||||
let language = crate::gpui_shell::config::ui_language(cx);
|
||||
let control = h_flex()
|
||||
.id("scroll-speed-control")
|
||||
.debug_selector(|| "scroll-speed-control".to_owned())
|
||||
.aria_label(language.text(Message::SettingsScrollingSpeed))
|
||||
.track_focus(&self.scroll_speed_focus)
|
||||
.w(px(220.0))
|
||||
.items_center()
|
||||
.gap_3()
|
||||
.border_1()
|
||||
.border_color(gpui::transparent_black())
|
||||
.rounded_md()
|
||||
.focus(|style| style.border_color(cx.theme().ring))
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener(|this, _, window, cx| {
|
||||
window.focus(&this.scroll_speed_focus, cx);
|
||||
}),
|
||||
)
|
||||
.on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| {
|
||||
let value = match event.keystroke.key.as_str() {
|
||||
"left" | "down" => this.runtime.scroll_speed - SCROLL_SPEED_STEP,
|
||||
"right" | "up" => this.runtime.scroll_speed + SCROLL_SPEED_STEP,
|
||||
"home" => MIN_SCROLL_SPEED,
|
||||
"end" => MAX_SCROLL_SPEED,
|
||||
_ => return,
|
||||
};
|
||||
let value = normalize_scroll_speed(value);
|
||||
this.preview_scroll_speed(value, cx);
|
||||
this.scroll_speed_slider.update(cx, |state, cx| state.set_value(value, window, cx));
|
||||
this.commit_scroll_speed(value, window, cx);
|
||||
cx.stop_propagation();
|
||||
}))
|
||||
.child(div().flex_1().min_w_0().child(Slider::new(&self.scroll_speed_slider)))
|
||||
.child(
|
||||
div()
|
||||
.w(px(48.0))
|
||||
.flex_shrink_0()
|
||||
.child(format!("{:.2}×", self.runtime.scroll_speed)),
|
||||
);
|
||||
self.row(
|
||||
language.text(Message::SettingsScrollingSpeed),
|
||||
language.text(Message::SettingsScrollingSpeedDescription),
|
||||
control,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,85 @@ fn test_runtime() -> RuntimeSettings {
|
||||
RuntimeSettings::from_raw(&RawSettings::from_text(TEST_SETTINGS))
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn scrolling_controls_persist_dropdown_and_slider_without_changing_existing_history(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
use crate::gpui_shell::config::Settings;
|
||||
use nebula_terminal::event::VoidListener;
|
||||
use nebula_terminal::grid::Dimensions;
|
||||
use nebula_terminal::term::{Term, test::TermSize};
|
||||
|
||||
let _fixture_guard = lock_theme_studio();
|
||||
let _settings_guard = SettingsBytesGuard::capture();
|
||||
std::fs::create_dir_all(nebula_settings::settings_dir()).unwrap();
|
||||
std::fs::write(nebula_settings::settings_path(), TEST_SETTINGS).unwrap();
|
||||
let (pane, mut window) = open_settings(cx);
|
||||
pane.update(&mut window, |pane, cx| {
|
||||
pane.active_section = 1;
|
||||
cx.notify();
|
||||
});
|
||||
window.simulate_resize(size(px(1280.0), px(1800.0)));
|
||||
draw(&mut window);
|
||||
let old_config = window.read(|cx| cx.global::<Settings>().term_config());
|
||||
let mut existing = Term::new(old_config, &TermSize::new(80, 24), VoidListener);
|
||||
existing.grid_mut().initialize_all();
|
||||
assert_eq!(existing.grid().history_size(), 10_000);
|
||||
|
||||
assert_eq!(
|
||||
localized_select_labels(
|
||||
"scrollback_lines",
|
||||
nebula_settings::SCROLLBACK_VALUES,
|
||||
crate::display::UiLanguage::ZhCn
|
||||
),
|
||||
["1,000", "2,000", "5,000", "10,000", "20,000", "50,000", "100,000"]
|
||||
.map(SharedString::from)
|
||||
);
|
||||
click("settings-select-scrollback_lines", &mut window);
|
||||
for _ in 0..3 {
|
||||
press("down", &mut window);
|
||||
}
|
||||
press("enter", &mut window);
|
||||
assert_eq!(RuntimeSettings::load().scrollback_lines, 100_000);
|
||||
assert_eq!(window.read(|cx| cx.global::<Settings>().term_config().scrolling_history), 100_000);
|
||||
assert_eq!(existing.grid().history_size(), 10_000);
|
||||
|
||||
let bounds = window.debug_bounds("scroll-speed-control").expect("scroll speed hitbox");
|
||||
let point = gpui::point(bounds.origin.x + px(80.0), bounds.center().y);
|
||||
window.simulate_mouse_down(point, MouseButton::Left, Modifiers::default());
|
||||
draw(&mut window);
|
||||
let preview = pane.read_with(&mut window, |pane, _| pane.runtime.scroll_speed);
|
||||
assert_ne!(preview, 1.0);
|
||||
assert_eq!(window.read(|cx| cx.global::<Settings>().scroll_speed), preview);
|
||||
assert_eq!(
|
||||
RuntimeSettings::load().scroll_speed,
|
||||
1.0,
|
||||
"dragging does not write settings on every frame"
|
||||
);
|
||||
window.simulate_mouse_up(point, MouseButton::Left, Modifiers::default());
|
||||
draw(&mut window);
|
||||
assert_eq!(RuntimeSettings::load().scroll_speed, preview);
|
||||
press("home", &mut window);
|
||||
assert_eq!(RuntimeSettings::load().scroll_speed, 0.25);
|
||||
press("right", &mut window);
|
||||
assert_eq!(RuntimeSettings::load().scroll_speed, 0.5);
|
||||
|
||||
let reopened = window.update(|window, cx| cx.new(|cx| SettingsPane::new(window, cx)));
|
||||
reopened.read_with(&mut window, |pane, cx| {
|
||||
assert_eq!(pane.runtime.scrollback_lines, 100_000);
|
||||
assert_eq!(pane.scroll_speed_slider.read(cx).value().start(), 0.5);
|
||||
assert_eq!(
|
||||
pane.select_of("scrollback_lines").unwrap().read(cx).selected_value().unwrap().as_ref(),
|
||||
"100,000"
|
||||
);
|
||||
});
|
||||
pane.update_in(&mut window, |pane, window, cx| {
|
||||
pane.commit_scrollback_lines("1000", window, cx)
|
||||
});
|
||||
assert_eq!(RuntimeSettings::load().scrollback_lines, 1_000);
|
||||
assert_eq!(existing.grid().history_size(), 10_000);
|
||||
}
|
||||
|
||||
fn draw(cx: &mut VisualTestContext) {
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, cx| {
|
||||
|
||||
@@ -64,13 +64,16 @@ pub fn update(
|
||||
/// pane 状态清空等下一行。空行只清不记。
|
||||
pub fn commit_line(state: &mut NebulaPaneState) {
|
||||
let line = state.screen_line.trim().to_owned();
|
||||
let committed = if line.is_empty() { state.line_buf.trim().to_owned() } else { line.clone() };
|
||||
if !line.is_empty() {
|
||||
shared().history.record(&state.suggest_env.history_scope(), &line, &state.cwd);
|
||||
state.record_completion_command(&mut shared().history, &line);
|
||||
} else {
|
||||
state.completion_submitted(&state.line_buf.clone());
|
||||
}
|
||||
// 旧壳 `nebula_commit_line` 同一条:OSC 133;C 到达时 PTY 已把行缓冲清
|
||||
// 空,程序身份(侧栏 tab 图标)必须在 Enter 这一刻从屏幕真值捕获。
|
||||
// grid 读失败时退回按键镜像——取首 token 做身份已足够。
|
||||
state.last_committed = if line.is_empty() { state.line_buf.trim().to_owned() } else { line };
|
||||
state.last_committed = committed;
|
||||
crate::display::nebula_clear_line(state);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! 终端视图:持有会话、处理输入与 IME、驱动重绘。
|
||||
|
||||
mod broadcast;
|
||||
mod completion;
|
||||
mod confirmation;
|
||||
mod cwd_report;
|
||||
mod image_paste;
|
||||
@@ -505,11 +506,14 @@ impl TerminalView {
|
||||
let cwd_changed = cwd_report::apply(&mut self.cwd, &event);
|
||||
// Both shell metadata channels update completion and directory history.
|
||||
if cwd_changed
|
||||
|| (matches!(&event, TermEvent::Title(title) if title.starts_with("NEBULA|"))
|
||||
|| ((matches!(&event, TermEvent::CwdReport(_))
|
||||
|| matches!(&event, TermEvent::Title(title) if title.starts_with("NEBULA|")))
|
||||
&& self.suggest.cwd != self.cwd)
|
||||
{
|
||||
self.suggest.cwd = self.cwd.clone();
|
||||
super::suggest::record_directory(&self.cwd);
|
||||
if self.suggest.suggest_env.is_this_machine() {
|
||||
super::suggest::record_directory(&self.cwd);
|
||||
}
|
||||
}
|
||||
match event {
|
||||
TermEvent::Wakeup => {
|
||||
@@ -654,8 +658,10 @@ impl TerminalView {
|
||||
}
|
||||
},
|
||||
TermEvent::Bell => self.on_bell(cx),
|
||||
// 仍未接线:OSC 1337 UserVar(AI 查询拦截)。
|
||||
_ => {},
|
||||
TermEvent::UserVar { name, value } => {
|
||||
self.suggest.completion_shell_report(&name, &value);
|
||||
cx.notify();
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1009,173 +1015,6 @@ impl TerminalView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Enter 提交:从 grid 读回显真值(screen truth)记入共享历史,然后清
|
||||
/// 行镜像。读法与旧壳 `nebula_commit_line` 的 Windows 契约一致:无法证明
|
||||
/// 是提示符的 REPL 行或中线编辑读不到就宁缺毋滥——键击重构的
|
||||
/// line_buf 在光标移动/Tab 补全后就是拼接垃圾,不能进历史。Agent 已在
|
||||
/// 前台时保留最初 shell 提示符,内部交互的 Enter 不得覆盖退出证据。
|
||||
fn commit_line(&mut self, cx: &mut Context<Self>) {
|
||||
let agent_already_active =
|
||||
self.running_program.as_deref().and_then(crate::ai_agents::AgentKind::parse).is_some();
|
||||
if !agent_already_active {
|
||||
self.suggest.pending_command_prompt = None;
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if !agent_already_active && let Some(session) = &self.session {
|
||||
let term = session.term.lock();
|
||||
if !term.mode().intersects(TermMode::ALT_SCREEN | TermMode::VI) {
|
||||
let cursor = term.grid().cursor.point;
|
||||
match crate::display::nebula_prompt_line_from_raw_grid(
|
||||
&term,
|
||||
cursor,
|
||||
&self.suggest.line_buf,
|
||||
&self.suggest.suggest_env,
|
||||
) {
|
||||
Some(line) => {
|
||||
self.suggest.screen_line = line.input;
|
||||
self.suggest.pending_command_prompt = Some(line.prompt);
|
||||
},
|
||||
None => {
|
||||
self.suggest.screen_line.clear();
|
||||
self.suggest.pending_command_prompt = None;
|
||||
},
|
||||
}
|
||||
} else {
|
||||
self.suggest.screen_line.clear();
|
||||
self.suggest.pending_command_prompt = None;
|
||||
}
|
||||
}
|
||||
suggest::commit_line(&mut self.suggest);
|
||||
if let Some(agent) =
|
||||
crate::ai_agents::AgentKind::parse_command(&self.suggest.last_committed)
|
||||
{
|
||||
self.running_program = Some(agent.slug().to_owned());
|
||||
self.agent_status = crate::ai_agents::AgentStatus::Working;
|
||||
self.agent_status_source = crate::ai_agents::AgentStatusSource::Process;
|
||||
self.agent_status_rule = None;
|
||||
self.agent_hook_seen = false;
|
||||
self.agent_turn_active = true;
|
||||
self.idle_screen_streak = 0;
|
||||
self.command_started = Some(std::time::Instant::now());
|
||||
cx.emit(TerminalViewEvent::TitleChanged);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// 用元素在网格快照同一次 `Term` 锁内取得的提示行重算 ghost/弹窗。
|
||||
/// 这与旧壳 `draw_pane` 的锁序一致,避免退格回显夹在 render/paint 两次
|
||||
/// 取锁之间时拼成“旧提示 + 新光标”的跳动帧。
|
||||
pub(super) fn refresh_suggestion_from_snapshot(
|
||||
&mut self,
|
||||
line: Option<String>,
|
||||
anchor: Option<(usize, usize)>,
|
||||
) {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if self.exited.is_some()
|
||||
|| !self.ghost_enabled
|
||||
|| matches!(self.ssh_stage, Some(crate::ssh_session::SshStage::Failed(_)))
|
||||
{
|
||||
self.suggest_anchor = None;
|
||||
self.suggest.clear_completion_hints();
|
||||
self.completion_viewport.clear();
|
||||
return;
|
||||
}
|
||||
if self.session.is_none() {
|
||||
self.suggest_anchor = None;
|
||||
self.suggest.clear_completion_hints();
|
||||
self.completion_viewport.clear();
|
||||
return;
|
||||
}
|
||||
match line {
|
||||
Some(line) => {
|
||||
self.suggest_anchor = anchor;
|
||||
self.suggest.screen_line = line.clone();
|
||||
suggest::update(
|
||||
&mut self.suggest,
|
||||
Some(line),
|
||||
self.ghost_enabled,
|
||||
self.completion_style,
|
||||
);
|
||||
self.completion_viewport.update_query(
|
||||
&self.suggest.screen_line,
|
||||
self.suggest.completion_items.len(),
|
||||
);
|
||||
},
|
||||
None => {
|
||||
self.suggest_anchor = None;
|
||||
self.suggest.screen_line.clear();
|
||||
self.suggest.clear_completion_hints();
|
||||
self.completion_viewport.clear();
|
||||
},
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let _ = (line, anchor);
|
||||
}
|
||||
}
|
||||
|
||||
/// 补齐登记了一个还没缓存的来宾 / 远端目录时,去后台拉一次。
|
||||
///
|
||||
/// 补齐本身跑在按键路径上,绝不能做 IO——一次 `wsl.exe -- find` 冷启动实测
|
||||
/// 可达 7.5 秒,一次 SFTP 是完整的网络往返。所以它只把目录登记在
|
||||
/// `pending_remote_dir`,真正的往返在这里发生:结果进 [`crate::remote_dirs`]
|
||||
/// 的进程级缓存,代际一变,下一次重算就有候选了。
|
||||
///
|
||||
/// 用户的体感是"第一次 Tab 没反应,之后都有"——而不是"每次 Tab 卡住整个
|
||||
/// 窗口"。
|
||||
pub(super) fn drive_pending_remote_dir(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(dir) = self.suggest.pending_remote_dir.take() else { return };
|
||||
let env = self.suggest.suggest_env.clone();
|
||||
// 连按 Tab 不该排出一串子进程 / 往返。
|
||||
if !crate::remote_dirs::begin_fetch(&env, &dir) {
|
||||
return;
|
||||
}
|
||||
match env.clone() {
|
||||
crate::display::SuggestEnv::Wsl { distro } => {
|
||||
cx.spawn(async move |this, cx| {
|
||||
let target = dir.clone();
|
||||
// 子进程往返是阻塞的,必须落在后台线程池上。
|
||||
let entries = cx
|
||||
.background_spawn(
|
||||
async move { crate::remote_dirs::fetch_wsl(&distro, &target) },
|
||||
)
|
||||
.await;
|
||||
crate::remote_dirs::finish_fetch(&env, &dir, entries);
|
||||
let _ = this.update(cx, |_, cx| cx.notify());
|
||||
})
|
||||
.detach();
|
||||
},
|
||||
crate::display::SuggestEnv::Ssh { destination } => {
|
||||
// SSH 的 async 只能跑在项目自己的 tokio runtime 上(连接池和
|
||||
// 认证策略都在那儿),而这里要等的是 GPUI 的任务——用一条
|
||||
// oneshot 把两个 executor 接起来。
|
||||
let Ok(runtime) = crate::ssh_session::runtime() else { return };
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let target = dir.clone();
|
||||
runtime.spawn(async move {
|
||||
let listed =
|
||||
crate::ssh_sftp::list_dir_for_completion(&destination, &target).await;
|
||||
let _ = tx.send(listed);
|
||||
});
|
||||
cx.spawn(async move |this, cx| {
|
||||
let entries = rx.await.ok().flatten().map(|entries| {
|
||||
entries
|
||||
.into_iter()
|
||||
.map(|(is_dir, name)| crate::remote_dirs::RemoteEntry { name, is_dir })
|
||||
.collect()
|
||||
});
|
||||
crate::remote_dirs::finish_fetch(&env, &dir, entries);
|
||||
let _ = this.update(cx, |_, cx| cx.notify());
|
||||
})
|
||||
.detach();
|
||||
},
|
||||
// 本机 pane 的补齐直接读 `std::fs`,走不到这条路。
|
||||
crate::display::SuggestEnv::Local => {},
|
||||
}
|
||||
}
|
||||
|
||||
fn track_encoded_key(&mut self, ks: &gpui::Keystroke, mode: &TermMode, cx: &mut Context<Self>) {
|
||||
self.image_paste.observe_key(ks);
|
||||
if self.marked_text.is_some() || mode.contains(TermMode::ALT_SCREEN) {
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
//! Completion input capture and asynchronous directory requests for a pane.
|
||||
|
||||
use super::{TerminalView, TerminalViewEvent, suggest};
|
||||
use gpui::{AppContext as _, Context, EventEmitter as _};
|
||||
use nebula_terminal::term::TermMode;
|
||||
|
||||
impl TerminalView {
|
||||
/// Enter 提交:从 grid 读回显真值(screen truth)记入共享历史,然后清
|
||||
/// 行镜像。读法与旧壳 `nebula_commit_line` 的 Windows 契约一致:无法证明
|
||||
/// 是提示符的 REPL 行或中线编辑读不到就宁缺毋滥——键击重构的
|
||||
/// line_buf 在光标移动/Tab 补全后就是拼接垃圾,不能进历史。Agent 已在
|
||||
/// 前台时保留最初 shell 提示符,内部交互的 Enter 不得覆盖退出证据。
|
||||
pub(super) fn commit_line(&mut self, cx: &mut Context<Self>) {
|
||||
let agent_already_active =
|
||||
self.running_program.as_deref().and_then(crate::ai_agents::AgentKind::parse).is_some();
|
||||
if !agent_already_active {
|
||||
self.suggest.pending_command_prompt = None;
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if !agent_already_active && let Some(session) = &self.session {
|
||||
let term = session.term.lock();
|
||||
if !term.mode().intersects(TermMode::ALT_SCREEN | TermMode::VI) {
|
||||
let cursor = term.grid().cursor.point;
|
||||
match crate::display::nebula_prompt_line_from_raw_grid(
|
||||
&term,
|
||||
cursor,
|
||||
&self.suggest.line_buf,
|
||||
&self.suggest.suggest_env,
|
||||
) {
|
||||
Some(line) => {
|
||||
self.suggest.screen_line = line.input;
|
||||
self.suggest.pending_command_prompt = Some(line.prompt);
|
||||
},
|
||||
None => {
|
||||
self.suggest.screen_line.clear();
|
||||
self.suggest.pending_command_prompt = None;
|
||||
},
|
||||
}
|
||||
} else {
|
||||
self.suggest.screen_line.clear();
|
||||
self.suggest.pending_command_prompt = None;
|
||||
}
|
||||
}
|
||||
suggest::commit_line(&mut self.suggest);
|
||||
if let Some(agent) =
|
||||
crate::ai_agents::AgentKind::parse_command(&self.suggest.last_committed)
|
||||
{
|
||||
self.running_program = Some(agent.slug().to_owned());
|
||||
self.agent_status = crate::ai_agents::AgentStatus::Working;
|
||||
self.agent_status_source = crate::ai_agents::AgentStatusSource::Process;
|
||||
self.agent_status_rule = None;
|
||||
self.agent_hook_seen = false;
|
||||
self.agent_turn_active = true;
|
||||
self.idle_screen_streak = 0;
|
||||
self.command_started = Some(std::time::Instant::now());
|
||||
cx.emit(TerminalViewEvent::TitleChanged);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// 用元素在网格快照同一次 `Term` 锁内取得的提示行重算 ghost/弹窗。
|
||||
/// 这与旧壳 `draw_pane` 的锁序一致,避免退格回显夹在 render/paint 两次
|
||||
/// 取锁之间时拼成“旧提示 + 新光标”的跳动帧。
|
||||
pub(in crate::gpui_shell::terminal) fn refresh_suggestion_from_snapshot(
|
||||
&mut self,
|
||||
line: Option<String>,
|
||||
anchor: Option<(usize, usize)>,
|
||||
) {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if self.exited.is_some()
|
||||
|| !self.ghost_enabled
|
||||
|| matches!(self.ssh_stage, Some(crate::ssh_session::SshStage::Failed(_)))
|
||||
{
|
||||
self.suggest_anchor = None;
|
||||
self.suggest.clear_completion_hints();
|
||||
self.completion_viewport.clear();
|
||||
return;
|
||||
}
|
||||
if self.session.is_none() {
|
||||
self.suggest_anchor = None;
|
||||
self.suggest.clear_completion_hints();
|
||||
self.completion_viewport.clear();
|
||||
return;
|
||||
}
|
||||
match line {
|
||||
Some(line) => {
|
||||
self.suggest_anchor = anchor;
|
||||
self.suggest.screen_line = line.clone();
|
||||
suggest::update(
|
||||
&mut self.suggest,
|
||||
Some(line),
|
||||
self.ghost_enabled,
|
||||
self.completion_style,
|
||||
);
|
||||
self.completion_viewport.update_query(
|
||||
&self.suggest.screen_line,
|
||||
self.suggest.completion_items.len(),
|
||||
);
|
||||
},
|
||||
None => {
|
||||
self.suggest_anchor = None;
|
||||
self.suggest.screen_line.clear();
|
||||
self.suggest.clear_completion_hints();
|
||||
self.completion_viewport.clear();
|
||||
},
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let _ = (line, anchor);
|
||||
}
|
||||
}
|
||||
|
||||
/// 补齐登记了一个还没缓存的来宾 / 远端目录时,去后台拉一次。
|
||||
///
|
||||
/// 补齐本身跑在按键路径上,绝不能做 IO——一次 `wsl.exe -- find` 冷启动实测
|
||||
/// 可达 7.5 秒,一次 SFTP 是完整的网络往返。所以它只把目录登记在
|
||||
/// `pending_remote_dir`,真正的往返在这里发生:结果进 [`crate::remote_dirs`]
|
||||
/// 的进程级缓存,代际一变,下一次重算就有候选了。
|
||||
///
|
||||
/// 用户的体感是"第一次 Tab 没反应,之后都有"——而不是"每次 Tab 卡住整个
|
||||
/// 窗口"。
|
||||
pub(in crate::gpui_shell::terminal) fn drive_pending_remote_dir(
|
||||
&mut self,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(dir) = self.suggest.pending_remote_dir.take() else { return };
|
||||
let env = self.suggest.suggest_env.clone();
|
||||
// 连按 Tab 不该排出一串子进程 / 往返。
|
||||
if !crate::remote_dirs::begin_fetch(&env, &dir) {
|
||||
return;
|
||||
}
|
||||
match env.clone() {
|
||||
crate::display::SuggestEnv::Wsl { distro } => {
|
||||
cx.spawn(async move |this, cx| {
|
||||
let target = dir.clone();
|
||||
// 子进程往返是阻塞的,必须落在后台线程池上。
|
||||
let entries = cx
|
||||
.background_spawn(
|
||||
async move { crate::remote_dirs::fetch_wsl(&distro, &target) },
|
||||
)
|
||||
.await;
|
||||
crate::remote_dirs::finish_fetch(&env, &dir, entries);
|
||||
let _ = this.update(cx, |view, cx| {
|
||||
if view.suggest.suggest_env == env {
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
},
|
||||
crate::display::SuggestEnv::Ssh { destination } => {
|
||||
// SSH 的 async 只能跑在项目自己的 tokio runtime 上(连接池和
|
||||
// 认证策略都在那儿),而这里要等的是 GPUI 的任务——用一条
|
||||
// oneshot 把两个 executor 接起来。
|
||||
let Ok(runtime) = crate::ssh_session::runtime() else { return };
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let target = dir.clone();
|
||||
runtime.spawn(async move {
|
||||
let listed =
|
||||
crate::ssh_sftp::list_dir_for_completion(&destination, &target).await;
|
||||
let _ = tx.send(listed);
|
||||
});
|
||||
cx.spawn(async move |this, cx| {
|
||||
let entries = rx.await.ok().flatten().map(|entries| {
|
||||
entries
|
||||
.into_iter()
|
||||
.map(|(is_dir, name)| crate::remote_dirs::RemoteEntry { name, is_dir })
|
||||
.collect()
|
||||
});
|
||||
crate::remote_dirs::finish_fetch(&env, &dir, entries);
|
||||
let _ = this.update(cx, |view, cx| {
|
||||
if view.suggest.suggest_env == env {
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
},
|
||||
// 本机 pane 的补齐直接读 `std::fs`,走不到这条路。
|
||||
crate::display::SuggestEnv::Local | crate::display::SuggestEnv::Shell { .. } => {},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -857,12 +857,11 @@ impl TerminalView {
|
||||
cx.stop_propagation();
|
||||
return;
|
||||
}
|
||||
self.scroll_px += delta_y;
|
||||
let lines = (self.scroll_px / self.line_height.as_f32().max(1.0)).trunc() as i32;
|
||||
let speed = cx.try_global::<Settings>().map_or(1.0, |settings| settings.scroll_speed);
|
||||
let lines = wheel_scroll_lines(&mut self.scroll_px, event.delta, self.line_height, speed);
|
||||
if lines == 0 {
|
||||
return;
|
||||
}
|
||||
self.scroll_px -= lines as f32 * self.line_height.as_f32();
|
||||
let mode = self.term_mode();
|
||||
// 应用接管鼠标时滚轮也归应用(htop 列表滚动);Shift 旁路回本地回滚。
|
||||
if !event.modifiers.shift && mode.intersects(TermMode::MOUSE_MODE) {
|
||||
@@ -895,3 +894,80 @@ impl TerminalView {
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Preserve fractional deltas across events. Wheel speed must not rescale
|
||||
/// pixel-precise trackpad input, font zoom, or completion-list scrolling.
|
||||
fn wheel_scroll_lines(
|
||||
remainder: &mut f32,
|
||||
delta: gpui::ScrollDelta,
|
||||
line_height: Pixels,
|
||||
speed: f32,
|
||||
) -> i32 {
|
||||
let pixels = delta.pixel_delta(line_height).y.as_f32();
|
||||
let pixels = match delta {
|
||||
gpui::ScrollDelta::Lines(_) => pixels * speed,
|
||||
gpui::ScrollDelta::Pixels(_) => pixels,
|
||||
};
|
||||
if !pixels.is_finite() {
|
||||
return 0;
|
||||
}
|
||||
let height = line_height.as_f32().max(1.0);
|
||||
*remainder += pixels;
|
||||
let lines = (*remainder / height).trunc() as i32;
|
||||
*remainder -= lines as f32 * height;
|
||||
lines
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod scrolling_tests {
|
||||
use super::wheel_scroll_lines;
|
||||
use gpui::{ScrollDelta, point, px};
|
||||
|
||||
#[test]
|
||||
fn normal_wheel_speed_preserves_existing_steps() {
|
||||
let mut remainder = 0.0;
|
||||
assert_eq!(
|
||||
wheel_scroll_lines(&mut remainder, ScrollDelta::Lines(point(0.0, 3.0)), px(20.0), 1.0),
|
||||
3
|
||||
);
|
||||
assert_eq!(
|
||||
wheel_scroll_lines(&mut remainder, ScrollDelta::Lines(point(0.0, -3.0)), px(20.0), 2.0),
|
||||
-6
|
||||
);
|
||||
assert_eq!(remainder, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slow_wheel_accumulates_without_dropping_small_inputs() {
|
||||
let mut remainder = 0.0;
|
||||
let lines: Vec<_> = (0..4)
|
||||
.map(|_| {
|
||||
wheel_scroll_lines(
|
||||
&mut remainder,
|
||||
ScrollDelta::Lines(point(0.0, 1.0)),
|
||||
px(20.0),
|
||||
0.25,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(lines, [0, 0, 0, 1]);
|
||||
assert_eq!(remainder, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trackpad_pixels_are_independent_of_wheel_speed_and_keep_direction() {
|
||||
for speed in [0.25, 1.0, 4.0] {
|
||||
let mut remainder = 0.0;
|
||||
let delta = ScrollDelta::Pixels(point(px(0.0), px(5.0)));
|
||||
assert_eq!(
|
||||
(0..4)
|
||||
.map(|_| wheel_scroll_lines(&mut remainder, delta, px(20.0), speed))
|
||||
.sum::<i32>(),
|
||||
1
|
||||
);
|
||||
let delta = ScrollDelta::Pixels(point(px(0.0), px(-20.0)));
|
||||
assert_eq!(wheel_scroll_lines(&mut remainder, delta, px(20.0), speed), -1);
|
||||
assert_eq!(remainder, 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,12 +100,10 @@ impl TerminalView {
|
||||
// 和 PATH 描述的是另一台机器。
|
||||
let suggest_env = effective
|
||||
.as_ref()
|
||||
.and_then(|shell| {
|
||||
crate::shell_detect::wsl_launch_distro(shell.program(), shell.args())
|
||||
.map(|shell| {
|
||||
crate::completion_context::launch_environment(shell.program(), shell.args())
|
||||
})
|
||||
.map_or(crate::display::SuggestEnv::Local, |distro| {
|
||||
crate::display::SuggestEnv::Wsl { distro: distro.to_owned() }
|
||||
});
|
||||
.unwrap_or_default();
|
||||
let snapshot_shell = crate::platform::shell::snapshot_shell(effective.clone());
|
||||
let session_launch = snapshot_shell.as_ref().map_or(
|
||||
crate::session::LaunchSession::Default,
|
||||
|
||||
@@ -50,6 +50,7 @@ mod chemistry;
|
||||
mod cli;
|
||||
mod clipboard;
|
||||
mod codex_config;
|
||||
mod completion_context;
|
||||
mod config;
|
||||
mod config_cli;
|
||||
mod daemon;
|
||||
|
||||
@@ -103,6 +103,25 @@ pub fn uses_legacy_pty_bootstrap(id: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the same default distro that wsl.exe launches, without starting a
|
||||
/// subprocess on the pane-spawn path.
|
||||
pub(crate) fn default_wsl_distro() -> Option<String> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use winreg::{RegKey, enums::HKEY_CURRENT_USER};
|
||||
let lxss = RegKey::predef(HKEY_CURRENT_USER)
|
||||
.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Lxss")
|
||||
.ok()?;
|
||||
let guid: String = lxss.get_value("DefaultDistribution").ok()?;
|
||||
let distro: String = lxss.open_subkey(guid).ok()?.get_value("DistributionName").ok()?;
|
||||
(!distro.is_empty()).then_some(distro)
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -35,6 +35,11 @@ fn prepare_unix(options: &mut tty::Options) -> std::io::Result<()> {
|
||||
(".zprofile", include_str!("../../res/shell/zprofile")),
|
||||
(".zshrc", include_str!("../../res/shell/zshrc")),
|
||||
] {
|
||||
let content = if name == ".zshrc" {
|
||||
format!("{content}\n{}", tty::CONNECTION_SHELL)
|
||||
} else {
|
||||
content.to_owned()
|
||||
};
|
||||
crate::atomic_file::write(&directory.join(name), content.as_bytes())?;
|
||||
}
|
||||
if let Some(original) = std::env::var_os("ZDOTDIR") {
|
||||
@@ -53,7 +58,9 @@ fn prepare_unix(options: &mut tty::Options) -> std::io::Result<()> {
|
||||
"bash" => {
|
||||
std::fs::create_dir_all(&root)?;
|
||||
let init = root.join("bashrc");
|
||||
crate::atomic_file::write(&init, include_bytes!("../../res/shell/bashrc"))?;
|
||||
let content =
|
||||
format!("{}\n{}", include_str!("../../res/shell/bashrc"), tty::CONNECTION_SHELL);
|
||||
crate::atomic_file::write(&init, content.as_bytes())?;
|
||||
options.shell = Some(tty::Shell::new(
|
||||
program,
|
||||
vec!["--rcfile".into(), init.to_string_lossy().into_owned(), "-i".into()],
|
||||
|
||||
@@ -75,11 +75,15 @@ fn key(env: &SuggestEnv, dir: &str) -> String {
|
||||
SuggestEnv::Local => format!("local\u{0}{dir}"),
|
||||
SuggestEnv::Wsl { distro } => format!("wsl:{distro}\u{0}{dir}"),
|
||||
SuggestEnv::Ssh { destination } => format!("ssh:{destination}\u{0}{dir}"),
|
||||
SuggestEnv::Shell { .. } => format!("unavailable\0{dir}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 已缓存且未过期的条目。
|
||||
pub fn lookup(env: &SuggestEnv, dir: &str) -> Option<Vec<RemoteEntry>> {
|
||||
if !env.can_query_remote_paths() {
|
||||
return None;
|
||||
}
|
||||
let cache = cache();
|
||||
let cached = cache.by_dir.get(&key(env, dir))?;
|
||||
(cached.fetched.elapsed() < TTL).then(|| cached.entries.clone())
|
||||
@@ -87,7 +91,7 @@ pub fn lookup(env: &SuggestEnv, dir: &str) -> Option<Vec<RemoteEntry>> {
|
||||
|
||||
/// 认领一次拉取。`false` = 已经有人在拉这个目录,调用方不要再起一个。
|
||||
pub fn begin_fetch(env: &SuggestEnv, dir: &str) -> bool {
|
||||
cache().inflight.insert(key(env, dir))
|
||||
env.can_query_remote_paths() && cache().inflight.insert(key(env, dir))
|
||||
}
|
||||
|
||||
/// 拉取结束。`None` = 这次失败了——只解锁不落缓存,下次还会重试;落一个空
|
||||
|
||||
@@ -656,16 +656,18 @@ pub fn wsl_unc_cwd(located: &WslCwd) -> Option<std::path::PathBuf> {
|
||||
/// shell 正常启动。
|
||||
pub fn wsl_cwd_report_env(
|
||||
program: &str,
|
||||
args: &[String],
|
||||
_args: &[String],
|
||||
current_wslenv: Option<&str>,
|
||||
) -> Vec<(String, String)> {
|
||||
if wsl_launch_distro(program, args).is_none() {
|
||||
if crate::display::extract_program(program).as_deref() != Some("wsl") {
|
||||
return Vec::new();
|
||||
}
|
||||
// 用 `$PWD` 而不是 `$(pwd)`,整个 PROMPT_COMMAND 只有变量赋值与一个
|
||||
// builtin printf:每个提示符都执行也不 fork。初始提示符多发的 D 无害,
|
||||
// builtin printf;身份只在首个提示符编码一次。初始提示符多发的 D 无害,
|
||||
// Runtime submit barrier 会拒绝把它错配给尚未真正提交的新命令。
|
||||
const REPORT: &str = r#"__nebula_status=$?; printf '\033]133;D;%s\007\033]7;file://%s%s\007\033]133;A\007' "$__nebula_status" "${HOSTNAME:-wsl}" "$PWD""#;
|
||||
const REPORT: &str = r#"__nebula_status=$?; if [ -z "${__pebrel_shell_token:-}" ]; then __pebrel_shell_token=$(printf '%s' "wsl|${WSL_DISTRO_NAME:-}|bash:${HOSTNAME:-wsl}:${BASHPID:-$$}:$RANDOM" | base64 | tr -d '\r\n');
|
||||
__PEBREL_CONNECTION_HOOK__
|
||||
fi; printf '\033]1337;SetUserVar=pebrel_shell=%s\007\033]133;D;%s\007\033]7;file://%s%s\007\033]133;A\007' "$__pebrel_shell_token" "$__nebula_status" "${HOSTNAME:-wsl}" "$PWD""#;
|
||||
// 宿主侧可能已经有 WSLENV(别的工具设的),必须追加而不是覆盖。
|
||||
let mut wslenv = current_wslenv
|
||||
.map(str::to_owned)
|
||||
@@ -677,7 +679,9 @@ pub fn wsl_cwd_report_env(
|
||||
if !wslenv.split(':').any(|entry| entry == "PROMPT_COMMAND") {
|
||||
wslenv.push_str("PROMPT_COMMAND");
|
||||
}
|
||||
vec![("PROMPT_COMMAND".to_owned(), REPORT.to_owned()), ("WSLENV".to_owned(), wslenv)]
|
||||
let report =
|
||||
REPORT.replace("__PEBREL_CONNECTION_HOOK__", nebula_terminal::tty::CONNECTION_SHELL);
|
||||
vec![("PROMPT_COMMAND".to_owned(), report), ("WSLENV".to_owned(), wslenv)]
|
||||
}
|
||||
|
||||
/// WSL automount 路径(仅 `/mnt/<盘>`)→ 宿主可见且已确认存在的目录。
|
||||
|
||||
+58
-3
@@ -25,9 +25,11 @@
|
||||
#[cfg(windows)]
|
||||
const REMOTE_BASH: &str = r#"
|
||||
[ -f "$HOME/.bashrc" ] && . "$HOME/.bashrc"
|
||||
__pebrel_shell_token=$(printf '%s' "bash:${HOSTNAME:-remote}:${BASHPID:-$$}:$RANDOM" | base64 | tr -d '\r\n')
|
||||
__nebula_branch=""
|
||||
__nebula_at_prompt=0
|
||||
__nebula_precmd() {
|
||||
printf '\033]1337;SetUserVar=pebrel_shell=%s\007' "$__pebrel_shell_token"
|
||||
printf '\033]133;D\007'
|
||||
if command -v git >/dev/null 2>&1; then
|
||||
__nebula_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
|
||||
@@ -67,8 +69,10 @@ const REMOTE_ZSH: &str = r#"
|
||||
[ -f "$HOME/.zshenv" ] && source "$HOME/.zshenv"
|
||||
[ -f "$HOME/.zshrc" ] && source "$HOME/.zshrc"
|
||||
autoload -Uz add-zsh-hook 2>/dev/null
|
||||
__pebrel_shell_token=$(printf '%s' "zsh:${HOST:-remote}:$$:$RANDOM" | base64 | tr -d '\r\n')
|
||||
__nebula_branch=""
|
||||
__nebula_precmd() {
|
||||
printf '\033]1337;SetUserVar=pebrel_shell=%s\007' "$__pebrel_shell_token"
|
||||
printf '\033]133;D\007'
|
||||
if command -v git >/dev/null 2>&1; then
|
||||
__nebula_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null)"
|
||||
@@ -122,10 +126,16 @@ pub const SAVE_CONNECTED_AFTER: std::time::Duration = std::time::Duration::from_
|
||||
/// session (`-N`/`-f`/`-G`/`-T`/`-V`/`-W`), or an explicit remote command
|
||||
/// (`ssh host ls`): none of those confirm as "connected to this host".
|
||||
pub fn ssh_destination(line: &str) -> Option<String> {
|
||||
let mut tokens = line.split_whitespace();
|
||||
let words = command_words(line)?;
|
||||
ssh_destination_words(&words)
|
||||
}
|
||||
|
||||
pub(crate) fn ssh_destination_words(words: &[String]) -> Option<String> {
|
||||
let mut tokens = words.iter().map(String::as_str);
|
||||
// Program identity, path/extension-normalized (`/usr/bin/ssh`,
|
||||
// `ssh.exe`); `nebula ssh …` counts too — same wrapper, same semantics.
|
||||
let mut program = crate::display::extract_program(tokens.next()?)?;
|
||||
let executable = tokens.next()?.rsplit(['/', '\\']).next()?;
|
||||
let mut program = crate::display::extract_program(executable)?;
|
||||
if matches!(program.as_str(), "pebrel" | "nebula") {
|
||||
if tokens.next() != Some("ssh") {
|
||||
return None;
|
||||
@@ -177,6 +187,39 @@ pub fn ssh_destination(line: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Literal argv for an individual entered command. Preserve quoted executable
|
||||
/// paths and option values; a compound shell expression is not one SSH login.
|
||||
pub(crate) fn command_words(line: &str) -> Option<Vec<String>> {
|
||||
let line = line.trim().strip_prefix("& ").unwrap_or(line.trim());
|
||||
let mut words = Vec::new();
|
||||
let mut word = String::new();
|
||||
let mut quote = None;
|
||||
for c in line.chars() {
|
||||
if quote == Some(c) {
|
||||
quote = None;
|
||||
} else if quote.is_some() {
|
||||
word.push(c);
|
||||
} else if matches!(c, '\'' | '"') {
|
||||
quote = Some(c);
|
||||
} else if matches!(c, ';' | '|' | '&' | '\n' | '\r') {
|
||||
return None;
|
||||
} else if c.is_whitespace() {
|
||||
if !word.is_empty() {
|
||||
words.push(std::mem::take(&mut word));
|
||||
}
|
||||
} else {
|
||||
word.push(c);
|
||||
}
|
||||
}
|
||||
if quote.is_some() {
|
||||
return None;
|
||||
}
|
||||
if !word.is_empty() {
|
||||
words.push(word);
|
||||
}
|
||||
(!words.is_empty()).then_some(words)
|
||||
}
|
||||
|
||||
/// Fold `-l user` / `-p port` into a destination string that reconnects when
|
||||
/// handed back to `nebula ssh` as a single argument.
|
||||
fn format_destination(dest: &str, login_user: Option<&str>, port: Option<&str>) -> String {
|
||||
@@ -581,7 +624,11 @@ pub fn run(args: Vec<String>) -> i32 {
|
||||
},
|
||||
SshPlan::Inject => {
|
||||
let b64 = base64::engine::general_purpose::STANDARD;
|
||||
let bootstrap = build_bootstrap(&b64.encode(REMOTE_BASH), &b64.encode(REMOTE_ZSH));
|
||||
let shell = nebula_terminal::tty::CONNECTION_SHELL;
|
||||
let bootstrap = build_bootstrap(
|
||||
&b64.encode(format!("{REMOTE_BASH}\n{shell}")),
|
||||
&b64.encode(format!("{REMOTE_ZSH}\n{shell}")),
|
||||
);
|
||||
// Keepalive: long-idle managed sessions (claude/codex runs) were
|
||||
// dropped by NAT/firewall idle timeouts. Application-level pings
|
||||
// every 30s survive those; 6 missed replies ≈ 3 min before the
|
||||
@@ -690,6 +737,11 @@ mod tests {
|
||||
assert_eq!(d("ssh user@host"), Some("user@host".into()));
|
||||
assert_eq!(d(" ssh.exe user@host "), Some("user@host".into()));
|
||||
assert_eq!(d("/usr/bin/ssh host"), Some("host".into()));
|
||||
assert_eq!(
|
||||
d(r#"& 'C:\Program Files\OpenSSH\ssh.exe' -i 'key file' host"#),
|
||||
Some("host".into())
|
||||
);
|
||||
assert_eq!(d(r#"ssh -o 'ProxyCommand=ssh -W %h:%p jump' host"#), Some("host".into()));
|
||||
assert_eq!(d("nebula ssh host"), Some("host".into()));
|
||||
assert_eq!(d("pebrel ssh host"), Some("host".into()));
|
||||
assert_eq!(d("pebrel.exe ssh -- user@host"), Some("user@host".into()));
|
||||
@@ -726,6 +778,9 @@ mod tests {
|
||||
assert_eq!(d("ssh -fN host"), None);
|
||||
assert_eq!(d("ssh -W target:22 jump"), None);
|
||||
assert_eq!(d("ssh -V"), None);
|
||||
assert_eq!(d("ssh host; echo done"), None);
|
||||
assert_eq!(d("ssh host | cat"), None);
|
||||
assert_eq!(d("ssh 'host"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -556,8 +556,7 @@ impl WindowContext {
|
||||
event_proxy.send_event(TerminalEvent::CursorBlinkingChange.into());
|
||||
}
|
||||
|
||||
let mut nebula_state = NebulaPaneState::default();
|
||||
nebula_state.cwd = initial_cwd;
|
||||
let nebula_state = crate::completion_context::initial_state(&pty_config, initial_cwd);
|
||||
|
||||
Ok(Pane {
|
||||
terminal,
|
||||
|
||||
@@ -40,6 +40,9 @@ impl WindowContext {
|
||||
if config.cursor.style().blinking {
|
||||
event_proxy.send_event(TerminalEvent::CursorBlinkingChange.into());
|
||||
}
|
||||
let mut nebula_state = NebulaPaneState::default();
|
||||
nebula_state.suggest_env =
|
||||
crate::display::SuggestEnv::Ssh { destination: destination.clone() };
|
||||
Ok(Pane {
|
||||
terminal,
|
||||
notifier: Notifier(sender),
|
||||
@@ -49,7 +52,7 @@ impl WindowContext {
|
||||
title: String::from("ssh"),
|
||||
exec_context: None,
|
||||
ssh_destination: Some(destination),
|
||||
nebula_state: NebulaPaneState::default(),
|
||||
nebula_state,
|
||||
intro_cols: None,
|
||||
shell_pid: 0,
|
||||
window_route,
|
||||
|
||||
@@ -25,6 +25,11 @@ pub use custom_theme::{
|
||||
};
|
||||
mod language;
|
||||
mod quick_terminal;
|
||||
mod scrolling;
|
||||
pub use scrolling::{
|
||||
DEFAULT_SCROLL_SPEED, DEFAULT_SCROLLBACK_LINES, MAX_SCROLL_SPEED, MIN_SCROLL_SPEED,
|
||||
SCROLL_SPEED_STEP, SCROLLBACK_VALUES, normalize_scroll_speed,
|
||||
};
|
||||
mod themes;
|
||||
pub use language::{LanguageInfo, LanguagePref};
|
||||
pub use quick_terminal::{QuickTerminalMode, QuickTerminalSize};
|
||||
@@ -959,6 +964,10 @@ pub struct RuntimeSettings {
|
||||
pub cursor_shape: Option<CursorShapeName>,
|
||||
pub cursor_blink: Option<bool>,
|
||||
pub copy_on_select: bool,
|
||||
/// Maximum retained history for new terminals, without altering open sessions.
|
||||
pub scrollback_lines: usize,
|
||||
/// Wheel multiplier; pixel-precise trackpad input is independent.
|
||||
pub scroll_speed: f32,
|
||||
/// GUI override for mouse.focus_follows_mouse in TOML; absent there too means false.
|
||||
pub focus_follows_mouse: Option<bool>,
|
||||
/// Preserve the existing dimming of inactive split panes unless explicitly disabled.
|
||||
@@ -1122,6 +1131,10 @@ impl RuntimeSettings {
|
||||
cursor_shape: raw.value("cursor_shape").and_then(CursorShapeName::from_settings),
|
||||
cursor_blink: raw.bool_on("cursor_blink"),
|
||||
copy_on_select: raw.bool_on("copy_on_select").unwrap_or(false),
|
||||
scrollback_lines: scrolling::scrollback_lines(raw),
|
||||
scroll_speed: normalize_scroll_speed(
|
||||
raw.f32("scroll_speed").unwrap_or(DEFAULT_SCROLL_SPEED),
|
||||
),
|
||||
focus_follows_mouse: raw.bool_on("focus_follows_mouse"),
|
||||
dim_inactive_panes: raw.bool_on("dim_inactive_panes").unwrap_or(true),
|
||||
multiline_paste_confirm: raw.bool_on("multiline_paste_confirm").unwrap_or(true),
|
||||
|
||||
@@ -5,6 +5,8 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const RESET_KEYS: &[&str] = &[
|
||||
"scrollback_lines",
|
||||
"scroll_speed",
|
||||
"language",
|
||||
"theme",
|
||||
"app_icon",
|
||||
@@ -125,6 +127,16 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::{RawSettings, RuntimeSettings};
|
||||
|
||||
#[test]
|
||||
fn reset_restores_scrolling_defaults_and_preserves_unknown_keys() {
|
||||
let restored =
|
||||
default_settings_text("scrollback_lines=100000\nscroll_speed=4.00\ncustom=keep\n");
|
||||
assert_eq!(restored, "custom=keep\n");
|
||||
let runtime = RuntimeSettings::from_raw(&RawSettings::from_text(&restored));
|
||||
assert_eq!(runtime.scrollback_lines, 10_000);
|
||||
assert_eq!(runtime.scroll_speed, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_restores_split_dimming_and_removes_mouse_override() {
|
||||
let restored =
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
use crate::RawSettings;
|
||||
|
||||
/// Persisted choices for newly created terminals; existing history is never resized by this key.
|
||||
pub const SCROLLBACK_VALUES: &[&str] =
|
||||
&["1000", "2000", "5000", "10000", "20000", "50000", "100000"];
|
||||
pub const DEFAULT_SCROLLBACK_LINES: usize = 10_000;
|
||||
pub const DEFAULT_SCROLL_SPEED: f32 = 1.0;
|
||||
pub const MIN_SCROLL_SPEED: f32 = 0.25;
|
||||
pub const MAX_SCROLL_SPEED: f32 = 4.0;
|
||||
pub const SCROLL_SPEED_STEP: f32 = 0.25;
|
||||
|
||||
pub(crate) fn scrollback_lines(raw: &RawSettings) -> usize {
|
||||
raw.value("scrollback_lines")
|
||||
.filter(|value| SCROLLBACK_VALUES.contains(value))
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(DEFAULT_SCROLLBACK_LINES)
|
||||
}
|
||||
|
||||
pub fn normalize_scroll_speed(value: f32) -> f32 {
|
||||
if value.is_finite() {
|
||||
value.clamp(MIN_SCROLL_SPEED, MAX_SCROLL_SPEED)
|
||||
} else {
|
||||
DEFAULT_SCROLL_SPEED
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{RuntimeSettings, apply_updates};
|
||||
|
||||
#[test]
|
||||
fn all_seven_history_choices_round_trip_without_changing_other_preferences() {
|
||||
assert_eq!(
|
||||
SCROLLBACK_VALUES,
|
||||
["1000", "2000", "5000", "10000", "20000", "50000", "100000"]
|
||||
);
|
||||
for value in SCROLLBACK_VALUES {
|
||||
let text = apply_updates(
|
||||
"font_size=18\ncustom=keep\n",
|
||||
&[("scrollback_lines", (*value).into())],
|
||||
);
|
||||
let runtime = RuntimeSettings::from_raw(&RawSettings::from_text(&text));
|
||||
assert_eq!(runtime.scrollback_lines, value.parse::<usize>().unwrap());
|
||||
assert_eq!(runtime.font_size_px, Some(18.0));
|
||||
assert!(text.contains("custom=keep"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_history_preferences_preserve_the_existing_default_limit() {
|
||||
for value in ["", "0", "-1", "999", "200000", "1,000", "invalid"] {
|
||||
let runtime = RuntimeSettings::from_raw(&RawSettings::from_text(&format!(
|
||||
"scrollback_lines={value}\n"
|
||||
)));
|
||||
assert_eq!(runtime.scrollback_lines, 10_000, "{value}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_preserves_fractional_multipliers_and_rejects_nonfinite_values() {
|
||||
for (value, expected) in [
|
||||
("0.25", 0.25),
|
||||
("1", 1.0),
|
||||
("1.75", 1.75),
|
||||
("4", 4.0),
|
||||
("-1", 0.25),
|
||||
("99", 4.0),
|
||||
("NaN", 1.0),
|
||||
("inf", 1.0),
|
||||
("invalid", 1.0),
|
||||
] {
|
||||
let text = apply_updates("scrollback_lines=50000\n", &[("scroll_speed", value.into())]);
|
||||
let runtime = RuntimeSettings::from_raw(&RawSettings::from_text(&text));
|
||||
assert_eq!(runtime.scroll_speed, expected, "{value}");
|
||||
assert_eq!(runtime.scrollback_lines, 50_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,11 +195,15 @@ impl StreamProcessor {
|
||||
bytes: &[u8],
|
||||
) {
|
||||
let osc_events = self.cwd_sniffer.feed(bytes);
|
||||
let mut latest_cwd = None;
|
||||
let mut advanced = 0;
|
||||
for (offset, event) in osc_events {
|
||||
// Titles and shell identity/cwd reports must retain wire order:
|
||||
// coalescing a remote cwd past a parent prompt would attribute that
|
||||
// directory to the parent shell's completion history.
|
||||
self.parser.advance(terminal, &bytes[advanced..offset]);
|
||||
advanced = offset;
|
||||
match event {
|
||||
OscEvent::Cwd(cwd) => latest_cwd = Some(cwd),
|
||||
OscEvent::Cwd(cwd) => event_proxy.send_event(Event::CwdReport(cwd)),
|
||||
OscEvent::CommandStart => {
|
||||
terminal.nebula_end_prompt();
|
||||
event_proxy.send_event(Event::CommandStart);
|
||||
@@ -221,13 +225,9 @@ impl StreamProcessor {
|
||||
}
|
||||
},
|
||||
OscEvent::PromptMark => {
|
||||
self.parser.advance(terminal, &bytes[advanced..offset]);
|
||||
advanced = offset;
|
||||
terminal.nebula_add_prompt_mark();
|
||||
},
|
||||
OscEvent::InlineImage { data, width, height } => {
|
||||
self.parser.advance(terminal, &bytes[advanced..offset]);
|
||||
advanced = offset;
|
||||
let (cell_w, cell_h) = self.window_size.map_or((9.0, 20.0), |ws| {
|
||||
(f32::from(ws.cell_width), f32::from(ws.cell_height))
|
||||
});
|
||||
@@ -250,9 +250,6 @@ impl StreamProcessor {
|
||||
}
|
||||
}
|
||||
self.parser.advance(terminal, &bytes[advanced..]);
|
||||
if let Some(cwd) = latest_cwd {
|
||||
event_proxy.send_event(Event::CwdReport(cwd));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -920,6 +917,37 @@ mod tests {
|
||||
use crate::term::Config;
|
||||
use crate::term::test::TermSize;
|
||||
|
||||
#[test]
|
||||
fn shell_identity_cwd_and_title_keep_wire_order_across_chunk_boundaries() {
|
||||
#[derive(Clone, Default)]
|
||||
struct Listener(std::sync::Arc<std::sync::Mutex<Vec<String>>>);
|
||||
impl EventListener for Listener {
|
||||
fn send_event(&self, event: Event) {
|
||||
let label = match event {
|
||||
Event::UserVar { value, .. } => format!("shell:{value}"),
|
||||
Event::CwdReport(cwd) => format!("cwd:{cwd}"),
|
||||
Event::Title(title) => format!("title:{title}"),
|
||||
_ => return,
|
||||
};
|
||||
self.0.lock().unwrap().push(label);
|
||||
}
|
||||
}
|
||||
let bytes = b"\x1b]1337;SetUserVar=pebrel_shell=cmVtb3Rl\x07\x1b]7;file://box/remote\x07\x1b]2;remote\x07\x1b]1337;SetUserVar=pebrel_shell=bG9jYWw=\x07\x1b]7;file://localhost/local\x07";
|
||||
for split in 0..=bytes.len() {
|
||||
let listener = Listener::default();
|
||||
let size = TermSize::new(80, 24);
|
||||
let mut terminal = Term::new(Config::default(), &size, listener.clone());
|
||||
let mut stream = StreamProcessor::default();
|
||||
stream.feed(&mut terminal, &listener, &bytes[..split]);
|
||||
stream.feed(&mut terminal, &listener, &bytes[split..]);
|
||||
assert_eq!(
|
||||
*listener.0.lock().unwrap(),
|
||||
["shell:remote", "cwd:/remote", "title:remote", "shell:local", "cwd:/local"],
|
||||
"split at {split}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_semantic_events_track_the_active_prompt() {
|
||||
let size = TermSize::new(80, 24);
|
||||
|
||||
@@ -11,6 +11,9 @@ use crate::grid::GridCell;
|
||||
use crate::index::Column;
|
||||
use crate::term::cell::ResetDiscriminant;
|
||||
|
||||
/// Avoid reallocating on every column while still releasing materially oversized rows.
|
||||
const MIN_RECLAIMABLE_CELLS: usize = 32;
|
||||
|
||||
/// A row in the grid.
|
||||
#[derive(Default, Clone, Debug)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
@@ -87,6 +90,7 @@ impl<T: Default> Row<T> {
|
||||
T: GridCell,
|
||||
{
|
||||
if self.inner.len() <= columns {
|
||||
Self::reclaim_excess_capacity(&mut self.inner, columns);
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -97,9 +101,29 @@ impl<T: Default> Row<T> {
|
||||
let index = max(content, cursor);
|
||||
new_row.truncate(index);
|
||||
|
||||
Self::reclaim_excess_capacity(&mut self.inner, columns);
|
||||
|
||||
self.occ = min(self.occ, columns);
|
||||
|
||||
if new_row.is_empty() { None } else { Some(new_row) }
|
||||
if new_row.is_empty() {
|
||||
None
|
||||
} else {
|
||||
// Reflow may grow a short tail back to the new width. Keep that
|
||||
// much capacity so reclaiming it does not force an immediate grow.
|
||||
Self::reclaim_excess_capacity(&mut new_row, columns);
|
||||
Some(new_row)
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn reclaim_excess_capacity(cells: &mut Vec<T>, columns: usize) {
|
||||
let retained = max(cells.len(), columns);
|
||||
let capacity = cells.capacity();
|
||||
if capacity.saturating_sub(retained) >= MIN_RECLAIMABLE_CELLS
|
||||
&& capacity >= retained.saturating_mul(2)
|
||||
{
|
||||
cells.shrink_to(retained);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset all cells in the row to the `template` cell.
|
||||
@@ -312,3 +336,83 @@ impl<T> IndexMut<RangeToInclusive<Column>> for Row<T> {
|
||||
&mut self.inner[..=(index.end.0)]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Row;
|
||||
use crate::index::Column;
|
||||
use crate::term::cell::Cell;
|
||||
|
||||
#[test]
|
||||
fn small_width_changes_keep_row_capacity() {
|
||||
let mut row = Row::<Cell>::new(100);
|
||||
let original_capacity = row.inner.capacity();
|
||||
|
||||
let _ = row.shrink(70);
|
||||
|
||||
assert_eq!(row.len(), 70);
|
||||
assert_eq!(row.inner.capacity(), original_capacity);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_width_changes_reclaim_row_capacity() {
|
||||
let mut row = Row::<Cell>::new(100);
|
||||
let original_capacity = row.inner.capacity();
|
||||
|
||||
let _ = row.shrink(40);
|
||||
|
||||
assert_eq!(row.len(), 40);
|
||||
assert!(row.inner.capacity() < original_capacity);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capacity_reclamation_requires_both_thresholds() {
|
||||
for (old, new, reclaim) in [(60, 30, false), (100, 60, false), (64, 32, true)] {
|
||||
let mut row = Row::<Cell>::new(old);
|
||||
let original_capacity = row.inner.capacity();
|
||||
|
||||
let _ = row.shrink(new);
|
||||
|
||||
assert_eq!(row.inner.capacity() < original_capacity, reclaim, "{old} -> {new}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_rows_reclaim_without_losing_occupied_cells() {
|
||||
let mut cells = Vec::with_capacity(200);
|
||||
cells.resize(20, Cell::default());
|
||||
cells[10].c = 'x';
|
||||
let mut row = Row::from_vec(cells, 11);
|
||||
|
||||
assert!(row.shrink(40).is_none());
|
||||
|
||||
assert_eq!(row.len(), 20);
|
||||
assert_eq!(row.occ, 11);
|
||||
assert_eq!(row[Column(10)].c, 'x');
|
||||
assert!(row.inner.capacity() < 200);
|
||||
let capacity = row.inner.capacity();
|
||||
row.grow(40);
|
||||
assert_eq!(row.inner.capacity(), capacity);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reclaimed_tail_preserves_content_and_cursor_space_without_regrowing_capacity() {
|
||||
let mut row = Row::<Cell>::new(200);
|
||||
row[Column(39)].c = 'a';
|
||||
row[Column(45)].c = '界';
|
||||
|
||||
let tail = row.shrink_with_minimum(40, 60).unwrap();
|
||||
|
||||
assert_eq!(row.len(), 40);
|
||||
assert_eq!(row.occ, 40);
|
||||
assert_eq!(row[Column(39)].c, 'a');
|
||||
assert_eq!(tail.len(), 20);
|
||||
assert_eq!(tail[5].c, '界');
|
||||
assert!(tail[6..].iter().all(|cell| *cell == Cell::default()));
|
||||
assert!(tail.capacity() < 160);
|
||||
let capacity = tail.capacity();
|
||||
let mut tail = Row::from_vec(tail, 20);
|
||||
tail.grow(40);
|
||||
assert_eq!(tail.inner.capacity(), capacity);
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,9 @@ use serde::{Deserialize, Serialize};
|
||||
use super::Row;
|
||||
use crate::index::Line;
|
||||
|
||||
/// Maximum number of buffered lines outside of the grid for performance optimization.
|
||||
const MAX_CACHE_SIZE: usize = 1_000;
|
||||
/// Bounds for the number of initialized rows kept ahead of active scrollback.
|
||||
const MIN_CACHE_SIZE: usize = 32;
|
||||
const MAX_CACHE_SIZE: usize = 128;
|
||||
|
||||
/// A ring buffer for optimizing indexing and rotation.
|
||||
///
|
||||
@@ -96,10 +97,10 @@ impl<T> Storage<T> {
|
||||
pub fn shrink_visible_lines(&mut self, next: usize) {
|
||||
// Shrink the size without removing any lines.
|
||||
let shrinkage = self.visible_lines - next;
|
||||
self.shrink_lines(shrinkage);
|
||||
|
||||
// Update visible lines.
|
||||
// Reclaim against the new viewport's cache allowance.
|
||||
self.visible_lines = next;
|
||||
self.shrink_lines(shrinkage);
|
||||
}
|
||||
|
||||
/// Shrink the number of lines in the buffer.
|
||||
@@ -108,7 +109,7 @@ impl<T> Storage<T> {
|
||||
self.len -= shrinkage;
|
||||
|
||||
// Free memory.
|
||||
if self.inner.len() > self.len + MAX_CACHE_SIZE {
|
||||
if self.inner.len() > self.len + self.cache_size() {
|
||||
self.truncate();
|
||||
}
|
||||
}
|
||||
@@ -130,7 +131,7 @@ impl<T> Storage<T> {
|
||||
if self.len + additional_rows > self.inner.len() {
|
||||
self.rezero();
|
||||
|
||||
let realloc_size = self.inner.len() + max(additional_rows, MAX_CACHE_SIZE);
|
||||
let realloc_size = self.inner.len() + max(additional_rows, self.cache_size());
|
||||
self.inner.resize_with(realloc_size, || Row::new(columns));
|
||||
}
|
||||
|
||||
@@ -142,6 +143,13 @@ impl<T> Storage<T> {
|
||||
self.len
|
||||
}
|
||||
|
||||
/// Keep roughly one viewport ready so line-at-a-time output does not have to
|
||||
/// rezero the ring on every scroll, without prebuilding thousands of cells.
|
||||
#[inline]
|
||||
fn cache_size(&self) -> usize {
|
||||
self.visible_lines.clamp(MIN_CACHE_SIZE, MAX_CACHE_SIZE)
|
||||
}
|
||||
|
||||
/// Swap implementation for Row<T>.
|
||||
///
|
||||
/// Exploits the known size of Row<T> to produce a slightly more efficient
|
||||
@@ -267,7 +275,7 @@ impl<T> IndexMut<Line> for Storage<T> {
|
||||
mod tests {
|
||||
use crate::grid::GridCell;
|
||||
use crate::grid::row::Row;
|
||||
use crate::grid::storage::{MAX_CACHE_SIZE, Storage};
|
||||
use crate::grid::storage::{MAX_CACHE_SIZE, MIN_CACHE_SIZE, Storage};
|
||||
use crate::index::{Column, Line};
|
||||
use crate::term::cell::Flags;
|
||||
|
||||
@@ -345,7 +353,7 @@ mod tests {
|
||||
/// 2: -
|
||||
/// 3: \0
|
||||
/// ...
|
||||
/// MAX_CACHE_SIZE: \0
|
||||
/// MIN_CACHE_SIZE: \0
|
||||
#[test]
|
||||
fn grow_after_zero() {
|
||||
// Setup storage area.
|
||||
@@ -366,7 +374,7 @@ mod tests {
|
||||
visible_lines: 4,
|
||||
len: 4,
|
||||
};
|
||||
expected.inner.append(&mut vec![filled_row('\0'); MAX_CACHE_SIZE]);
|
||||
expected.inner.append(&mut vec![filled_row('\0'); MIN_CACHE_SIZE]);
|
||||
|
||||
assert_eq!(storage.visible_lines, expected.visible_lines);
|
||||
assert_eq!(storage.inner, expected.inner);
|
||||
@@ -386,7 +394,7 @@ mod tests {
|
||||
/// 2: -
|
||||
/// 3: \0
|
||||
/// ...
|
||||
/// MAX_CACHE_SIZE: \0
|
||||
/// MIN_CACHE_SIZE: \0
|
||||
#[test]
|
||||
fn grow_before_zero() {
|
||||
// Setup storage area.
|
||||
@@ -407,7 +415,7 @@ mod tests {
|
||||
visible_lines: 4,
|
||||
len: 4,
|
||||
};
|
||||
expected.inner.append(&mut vec![filled_row('\0'); MAX_CACHE_SIZE]);
|
||||
expected.inner.append(&mut vec![filled_row('\0'); MIN_CACHE_SIZE]);
|
||||
|
||||
assert_eq!(storage.visible_lines, expected.visible_lines);
|
||||
assert_eq!(storage.inner, expected.inner);
|
||||
@@ -738,7 +746,7 @@ mod tests {
|
||||
filled_row('4'),
|
||||
filled_row('5'),
|
||||
];
|
||||
let expected_init_size = std::cmp::max(init_size, MAX_CACHE_SIZE);
|
||||
let expected_init_size = std::cmp::max(init_size, MIN_CACHE_SIZE);
|
||||
expected_inner.append(&mut vec![filled_row('\0'); expected_init_size]);
|
||||
let expected_storage = Storage { inner: expected_inner, zero: 0, visible_lines: 0, len: 9 };
|
||||
|
||||
@@ -747,6 +755,74 @@ mod tests {
|
||||
assert_eq!(storage.inner, expected_storage.inner);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initialize_caches_one_bounded_viewport() {
|
||||
let mut ordinary = Storage::<char>::with_capacity(30, 1);
|
||||
ordinary.initialize(1, 1);
|
||||
assert_eq!(ordinary.len, 31);
|
||||
assert_eq!(ordinary.inner.len(), 30 + MIN_CACHE_SIZE);
|
||||
|
||||
let mut medium = Storage::<char>::with_capacity(80, 1);
|
||||
medium.initialize(1, 1);
|
||||
assert_eq!(medium.inner.len(), 160);
|
||||
|
||||
let mut tall = Storage::<char>::with_capacity(200, 1);
|
||||
tall.initialize(1, 1);
|
||||
assert_eq!(tall.inner.len(), 200 + MAX_CACHE_SIZE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shrink_lines_releases_cache_beyond_one_viewport() {
|
||||
let mut storage = Storage::<char>::with_capacity(40, 1);
|
||||
storage.initialize(80, 1);
|
||||
assert_eq!(storage.inner.len(), 120);
|
||||
|
||||
storage.shrink_lines(80);
|
||||
|
||||
assert_eq!(storage.len, 40);
|
||||
assert_eq!(storage.inner.len(), 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shrinking_viewport_uses_the_smaller_cache_allowance() {
|
||||
let mut storage = Storage::<char>::with_capacity(100, 1);
|
||||
storage.initialize(1, 1);
|
||||
|
||||
storage.shrink_visible_lines(30);
|
||||
|
||||
assert_eq!(storage.visible_lines, 30);
|
||||
assert_eq!(storage.len, 31);
|
||||
assert_eq!(storage.inner.len(), 31);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_growth_and_rotated_reclamation_preserve_line_order() {
|
||||
let mut storage = Storage::<char>::with_capacity(40, 1);
|
||||
for value in 1..=400_u32 {
|
||||
storage.initialize(1, 1);
|
||||
storage.rotate(-1);
|
||||
storage[Line(39)][Column(0)] = char::from_u32(value).unwrap();
|
||||
assert!(storage.inner.len() - storage.len < 40);
|
||||
for offset in 0..value {
|
||||
assert_eq!(
|
||||
storage[Line(39 - offset as i32)][Column(0)],
|
||||
char::from_u32(value - offset).unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
storage.shrink_lines(350);
|
||||
|
||||
assert_eq!(storage.len, 90);
|
||||
assert_eq!(storage.inner.len(), 90);
|
||||
for offset in 0..90 {
|
||||
assert_eq!(
|
||||
storage[Line(39 - offset)][Column(0)],
|
||||
char::from_u32(400 - offset as u32).unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_wrap_zero() {
|
||||
let mut storage: Storage<char> = Storage {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Use the application's actual arguments after PowerShell has expanded variables
|
||||
# and arrays. Console writes stay outside PowerShell's success-output pipeline.
|
||||
function global:Invoke-PebrelConnection {
|
||||
param([string]$Program, [object[]]$Arguments)
|
||||
$application = Get-Command -Name $Program -CommandType Application -ErrorAction Stop | Select-Object -First 1
|
||||
$token = $global:PebrelShellToken
|
||||
$owner = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($token))
|
||||
$words = @($Program) + @($Arguments | ForEach-Object { [string]$_ })
|
||||
$payload = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($owner + "`n" + ($words -join [char]0)))
|
||||
[Console]::Write("$([char]27)]1337;SetUserVar=pebrel_shell=$token$([char]7)")
|
||||
[Console]::Write("$([char]27)]1337;SetUserVar=pebrel_connection=$payload$([char]7)")
|
||||
try {
|
||||
& $application @Arguments
|
||||
} finally {
|
||||
[Console]::Write("$([char]27)]1337;SetUserVar=pebrel_shell=$token$([char]7)")
|
||||
}
|
||||
}
|
||||
|
||||
# A user's function/alias keeps precedence. Fully qualified executable paths
|
||||
# also remain available as an explicit way to invoke the application directly.
|
||||
if ((Get-Command ssh -ErrorAction SilentlyContinue).CommandType -eq 'Application') {
|
||||
function global:ssh { Invoke-PebrelConnection 'ssh' $args }
|
||||
}
|
||||
if ((Get-Command ssh.exe -ErrorAction SilentlyContinue).CommandType -eq 'Application') {
|
||||
function global:ssh.exe { Invoke-PebrelConnection 'ssh' $args }
|
||||
}
|
||||
if ((Get-Command wsl -ErrorAction SilentlyContinue).CommandType -eq 'Application') {
|
||||
function global:wsl { Invoke-PebrelConnection 'wsl' $args }
|
||||
}
|
||||
if ((Get-Command wsl.exe -ErrorAction SilentlyContinue).CommandType -eq 'Application') {
|
||||
function global:wsl.exe { Invoke-PebrelConnection 'wsl' $args }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
# Bash/zsh connection boundaries. Arguments have already been expanded by the
|
||||
# shell; they are transported as NUL-separated fields, never evaluated again.
|
||||
__pebrel_connection() {
|
||||
local token="${__pebrel_shell_token:-}" report=0 encoded result
|
||||
if [ -t 1 ] && [ -n "$token" ]; then
|
||||
encoded=$(
|
||||
{
|
||||
printf '%s' "$token" | base64 -d
|
||||
printf '\n%s' "$1"
|
||||
shift
|
||||
if [ "$#" -gt 0 ]; then printf '\0%s' "$@"; fi
|
||||
} | base64 | tr -d '\r\n'
|
||||
)
|
||||
printf '\033]1337;SetUserVar=pebrel_shell=%s\007' "$token"
|
||||
printf '\033]1337;SetUserVar=pebrel_connection=%s\007' "$encoded"
|
||||
report=1
|
||||
fi
|
||||
command "$@"
|
||||
result=$?
|
||||
if [ "$report" = 1 ]; then
|
||||
printf '\033]1337;SetUserVar=pebrel_shell=%s\007' "$token"
|
||||
fi
|
||||
return "$result"
|
||||
}
|
||||
|
||||
# Preserve functions and aliases installed by the user's own shell config.
|
||||
if ! typeset -f ssh >/dev/null 2>&1 && ! alias ssh >/dev/null 2>&1; then
|
||||
function ssh { __pebrel_connection ssh "$@"; }
|
||||
fi
|
||||
if command -v wsl.exe >/dev/null 2>&1; then
|
||||
if ! typeset -f wsl >/dev/null 2>&1 && ! alias wsl >/dev/null 2>&1; then
|
||||
function wsl { __pebrel_connection wsl.exe "$@"; }
|
||||
fi
|
||||
fi
|
||||
@@ -18,6 +18,9 @@ pub mod windows;
|
||||
#[cfg(windows)]
|
||||
pub use self::windows::*;
|
||||
|
||||
/// Shared SSH/WSL execution reports for local and bootstrapped bash/zsh shells.
|
||||
pub const CONNECTION_SHELL: &str = include_str!("connection.sh");
|
||||
|
||||
/// Configuration for the `Pty` interface.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Default)]
|
||||
pub struct Options {
|
||||
|
||||
@@ -318,7 +318,8 @@ fn nebula_find_bash() -> Option<String> {
|
||||
/// makes the integrated experience look like Nebula out of the box instead of
|
||||
/// a bare PowerShell. ANSI sequences are emitted to stdout and rendered by the
|
||||
/// terminal itself, so colors work regardless of the PowerShell version.
|
||||
const NEBULA_PROMPT_PS1: &str = r#"
|
||||
const NEBULA_PROMPT_PS1: &str = concat!(
|
||||
r#"
|
||||
$global:NebE = [char]27
|
||||
$global:PSDefaultParameterValues['Get-Content:Encoding'] = 'utf8'
|
||||
$global:NebArrow = [char]0xE0B0
|
||||
@@ -407,6 +408,12 @@ if (-not $global:NebulaPromptInstalled) {
|
||||
$global:NebulaPromptInstalled = $true
|
||||
}
|
||||
|
||||
# A shell instance owns its completion context; children must get a new token.
|
||||
if (-not $global:PebrelShellToken) {
|
||||
$global:PebrelShellToken = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes(
|
||||
"pwsh:$PID`:$([Guid]::NewGuid().ToString('N'))"))
|
||||
}
|
||||
|
||||
function global:prompt {
|
||||
# Same principle as Oh My Posh: prompt rendering may execute external
|
||||
# commands, so preserve the previous command status. Errors inside the
|
||||
@@ -415,6 +422,7 @@ function global:prompt {
|
||||
# eating every user-facing error, e.g. a failed `cd`.)
|
||||
$originalDollarQuestion = $global:?
|
||||
$originalLastExitCode = $global:LASTEXITCODE
|
||||
[Console]::Write("$([char]27)]1337;SetUserVar=pebrel_shell=$global:PebrelShellToken$([char]7)")
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
$global:NebulaLastCommandSucceeded = $originalDollarQuestion
|
||||
$global:NebulaLastCommandExitCode = $originalLastExitCode
|
||||
@@ -652,13 +660,10 @@ function global:Convert-NebulaBareCd {
|
||||
return ($Matches['indent'] + $Matches['cmd'] + " '" + $escaped + "'")
|
||||
}
|
||||
|
||||
# oh-my-zsh-style experience: Nebula syntax colors. Prediction is OFF on
|
||||
# purpose: Nebula draws its own fish-style ghost hint, and running PSReadLine's
|
||||
# InlinePrediction alongside it double-renders a second gray hint AND races the
|
||||
# ghost-accept keys — the two sources desync and commit garbage like
|
||||
# "lsls sclaude" into history (which the hint then resurfaces, spooking users).
|
||||
# Keep the user's PSReadLine prediction configuration. The completion adapter
|
||||
# yields when the shell has text after the cursor, including native inline
|
||||
# predictions. Disabling Pebrel completion must not disable the shell's editor.
|
||||
if (Get-Command Set-PSReadLineOption -ErrorAction SilentlyContinue) {
|
||||
try { Set-PSReadLineOption -PredictionSource None -ErrorAction SilentlyContinue } catch {}
|
||||
try {
|
||||
# 不让 PowerShell 的 continuation prompt 回退成突兀的 `>>`,视觉上保持 Nebula 的单箭头。
|
||||
# 35=Magenta:主题表里的提示符色(浅色=优雅紫 #8250df),与主提示符一致。
|
||||
@@ -765,13 +770,28 @@ if (Get-Command Set-PSReadLineOption -ErrorAction SilentlyContinue) {
|
||||
# A blank Enter re-renders the prompt without running anything: no C,
|
||||
# so the spinner doesn't flash for a no-op.
|
||||
if (-not [string]::IsNullOrWhiteSpace($line)) {
|
||||
# The accepted PSReadLine text also sees recall, paste and native
|
||||
# completion. Resolve a simple PowerShell alias for scope tracking.
|
||||
$scopeLine = $line
|
||||
try {
|
||||
$first = ($line.Trim() -split '\s+', 2)[0]
|
||||
$alias = Get-Alias -Name $first -ErrorAction SilentlyContinue
|
||||
if ($alias -and $alias.ResolvedCommand) {
|
||||
$scopeLine = $alias.ResolvedCommand.Name + ' ' + $line.Trim().Substring($first.Length)
|
||||
}
|
||||
} catch {}
|
||||
$owner = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($global:PebrelShellToken))
|
||||
$scopeLine64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($owner + "`n" + $scopeLine))
|
||||
[Console]::Write("$([char]27)]1337;SetUserVar=pebrel_command=$scopeLine64$([char]7)")
|
||||
[Console]::Write("$([char]27)]133;C$([char]7)")
|
||||
}
|
||||
$line
|
||||
}
|
||||
}
|
||||
Clear-Host
|
||||
"#;
|
||||
"#,
|
||||
include_str!("../connection.ps1")
|
||||
);
|
||||
|
||||
/// Write `contents` to `path` only when it differs from what's already there.
|
||||
/// These integration scripts sit on every pane-spawn's critical path, and the
|
||||
@@ -897,11 +917,14 @@ __nebula_run_saved_prompt_command() {
|
||||
fi
|
||||
}
|
||||
|
||||
__pebrel_shell_token=$(printf '%s' "bash:${HOSTNAME:-localhost}:${BASHPID:-$$}:$RANDOM" | base64 | tr -d '\r\n')
|
||||
|
||||
__nebula_precmd() {
|
||||
# 同一个赋值语句会在任何 helper 改写状态前展开两者;分成两行会让
|
||||
# PIPESTATUS 只剩下 local/assignment 的结果,而不是用户的管道结果。
|
||||
NEBULA_CMD_STATUS=$? NEBULA_PIPE_STATUS=("${PIPESTATUS[@]}")
|
||||
local cmd_status="$NEBULA_CMD_STATUS" end_ms=""
|
||||
printf '\033]1337;SetUserVar=pebrel_shell=%s\007' "$__pebrel_shell_token"
|
||||
|
||||
if [[ -n ${NEBULA_COMMAND_START_MS-} ]]; then
|
||||
end_ms="$(__nebula_now_ms)"
|
||||
@@ -987,7 +1010,8 @@ fi
|
||||
|
||||
fn nebula_bash_rc_path() -> Option<std::path::PathBuf> {
|
||||
let path = std::env::temp_dir().join("pebrel_bashrc");
|
||||
write_if_changed(&path, NEBULA_BASH_RC.as_bytes()).then_some(path)
|
||||
let script = format!("{NEBULA_BASH_RC}\n{}", super::CONNECTION_SHELL);
|
||||
write_if_changed(&path, script.as_bytes()).then_some(path)
|
||||
}
|
||||
|
||||
fn explicit_bash_integration_args(rc: &std::path::Path) -> Vec<String> {
|
||||
@@ -1325,6 +1349,85 @@ $env:NEBULA_CONFIG_DIR = $env:PEBREL_CONFIG_DIR
|
||||
function global:Set-PSReadLineOption { }
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn powershell_integration_preserves_the_users_prediction_source() {
|
||||
run_powershell_integration_case(
|
||||
&format!(
|
||||
"{PS_PRELUDE}\n\
|
||||
$global:TestPredictionSource = 'HistoryAndPlugin'\n\
|
||||
function global:Set-PSReadLineOption {{\n\
|
||||
param($PredictionSource)\n\
|
||||
if ($PSBoundParameters.ContainsKey('PredictionSource')) {{\n\
|
||||
$global:TestPredictionSource = $PredictionSource\n\
|
||||
}}\n\
|
||||
}}"
|
||||
),
|
||||
"if ($global:TestPredictionSource -ne 'HistoryAndPlugin') { exit 90 }",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn powershell_reports_stable_shell_identity_and_accepted_alias_command() {
|
||||
run_powershell_integration_case(
|
||||
PS_PRELUDE,
|
||||
r#"
|
||||
function global:ssh { }
|
||||
Set-Alias -Name scope_ssh -Value ssh -Scope Global
|
||||
$global:NebulaPreviousPSConsoleHostReadLine = { 'scope_ssh user@box' }
|
||||
$capture = New-Object System.IO.StringWriter
|
||||
$originalOutput = [Console]::Out
|
||||
try {
|
||||
[Console]::SetOut($capture)
|
||||
$first = prompt
|
||||
$accepted = PSConsoleHostReadLine
|
||||
$second = prompt
|
||||
} finally { [Console]::SetOut($originalOutput) }
|
||||
if ($accepted -ne 'scope_ssh user@box') { exit 80 }
|
||||
$events = [regex]::Matches($capture.ToString(), 'SetUserVar=pebrel_shell=([^\x07]+)')
|
||||
if ($events.Count -ne 2) { exit 81 }
|
||||
if ($events[0].Groups[1].Value -ne $events[1].Groups[1].Value) { exit 82 }
|
||||
$identity = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($events[0].Groups[1].Value))
|
||||
if ($identity -notlike "pwsh:$PID`:*") { exit 83 }
|
||||
$command = [regex]::Match($capture.ToString(), 'SetUserVar=pebrel_command=([^\x07]+)')
|
||||
$decoded = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($command.Groups[1].Value))
|
||||
$parts = $decoded -split "`n", 2
|
||||
if ($parts[0] -ne $identity -or $parts[1] -notmatch '^ssh\s+user@box$') { exit 84 }
|
||||
exit 0
|
||||
"#,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn powershell_connection_preserves_arguments_exit_code_and_parent_report() {
|
||||
run_powershell_integration_case(
|
||||
PS_PRELUDE,
|
||||
r#"
|
||||
$capture = New-Object System.IO.StringWriter
|
||||
$originalOutput = [Console]::Out
|
||||
$argument = "space ' quote; pipe | dollar `$"
|
||||
$child = Join-Path $env:TEMP "pebrel-connection-argv-$PID.ps1"
|
||||
Set-Content -LiteralPath $child -Value '[Console]::Write($args[0]); exit 7' -Encoding UTF8
|
||||
try {
|
||||
[Console]::SetOut($capture)
|
||||
$output = Invoke-PebrelConnection 'powershell.exe' @('-NoProfile', '-NonInteractive', '-File', $child, $argument)
|
||||
$code = $LASTEXITCODE
|
||||
} finally {
|
||||
[Console]::SetOut($originalOutput)
|
||||
Remove-Item -LiteralPath $child
|
||||
}
|
||||
if ($code -ne 7 -or [string]$output -ne $argument) { exit 85 }
|
||||
$events = [regex]::Matches($capture.ToString(), 'SetUserVar=(pebrel_shell|pebrel_connection)=([^\x07]+)')
|
||||
if ($events.Count -ne 3) { exit 86 }
|
||||
if ($events[0].Groups[1].Value -ne 'pebrel_shell' -or $events[2].Groups[1].Value -ne 'pebrel_shell') { exit 87 }
|
||||
if ($events[0].Groups[2].Value -ne $events[2].Groups[2].Value) { exit 88 }
|
||||
$decoded = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($events[1].Groups[2].Value))
|
||||
$words = ($decoded -split "`n", 2)[1] -split [char]0
|
||||
if ($words.Count -ne 6 -or $words[-1] -ne $argument) { exit 89 }
|
||||
exit 0
|
||||
"#,
|
||||
);
|
||||
}
|
||||
|
||||
/// #80 的第二半:用户 `$PROFILE` 里的提示符(oh-my-posh/starship/手写)会
|
||||
/// 正常加载,但过去被 Nebula 的 powerline 盖掉,看起来就像 profile 没生效。
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Exercise the same Windows PTY and shell bootstrap used by the product.
|
||||
#![cfg(windows)]
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use nebula_terminal::event::WindowSize;
|
||||
use nebula_terminal::osc_cwd::{CwdSniffer, OscEvent};
|
||||
use nebula_terminal::tty::{self, EventedReadWrite, Options, Shell};
|
||||
|
||||
#[test]
|
||||
fn connection_reports_survive_native_conpty_in_execution_order() {
|
||||
let shell = tty::powershell_with_nebula_integration(
|
||||
"powershell.exe".into(),
|
||||
vec!["-NoLogo".into(), "-NoProfile".into()],
|
||||
);
|
||||
let mut args = shell.args().to_vec();
|
||||
// No network or stored credentials: this checks the real transport boundary.
|
||||
args.last_mut().unwrap().push_str(
|
||||
"; Invoke-PebrelConnection 'cmd.exe' @('/d', '/c', 'exit 7'); \
|
||||
[Console]::Write('PEBREL_TRANSPORT_DONE'); exit",
|
||||
);
|
||||
let options =
|
||||
Options { shell: Some(Shell::new(shell.program().into(), args)), ..Options::default() };
|
||||
let size = WindowSize { num_lines: 24, num_cols: 100, cell_width: 8, cell_height: 16 };
|
||||
let mut pty = tty::new(&options, size, 0).expect("create actual Windows PTY");
|
||||
let mut bytes = Vec::new();
|
||||
let mut sniffer = CwdSniffer::default();
|
||||
let mut reports = Vec::new();
|
||||
let mut cursor_replied = false;
|
||||
let deadline = Instant::now() + Duration::from_secs(20);
|
||||
while Instant::now() < deadline {
|
||||
let mut chunk = [0; 8192];
|
||||
match pty.reader().read(&mut chunk) {
|
||||
Ok(count) if count > 0 => {
|
||||
bytes.extend_from_slice(&chunk[..count]);
|
||||
reports.extend(sniffer.feed(&chunk[..count]).into_iter().filter_map(
|
||||
|(_, event)| match event {
|
||||
OscEvent::UserVar { name, value } => Some((name, value)),
|
||||
_ => None,
|
||||
},
|
||||
));
|
||||
if !cursor_replied && bytes.windows(4).any(|s| s == b"\x1b[6n") {
|
||||
cursor_replied = true;
|
||||
let _ = pty.writer().write_all(b"\x1b[1;1R");
|
||||
}
|
||||
if bytes.windows(21).any(|s| s == b"PEBREL_TRANSPORT_DONE") {
|
||||
break;
|
||||
}
|
||||
},
|
||||
Ok(_) => std::thread::sleep(Duration::from_millis(10)),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
},
|
||||
Err(error) => panic!("PTY read failed: {error}"),
|
||||
}
|
||||
}
|
||||
drop(pty);
|
||||
assert!(
|
||||
bytes.windows(21).any(|s| s == b"PEBREL_TRANSPORT_DONE"),
|
||||
"shell did not finish: {}",
|
||||
String::from_utf8_lossy(&bytes)
|
||||
);
|
||||
assert_eq!(reports.len(), 3, "unexpected native PTY reports: {reports:?}");
|
||||
assert_eq!(reports[0].0, "pebrel_shell");
|
||||
assert_eq!(reports[1].0, "pebrel_connection");
|
||||
assert_eq!(reports[2], reports[0], "return must restore the same owner");
|
||||
let (owner, argv) = reports[1].1.split_once('\n').unwrap();
|
||||
assert_eq!(owner, reports[0].1);
|
||||
assert_eq!(argv, "cmd.exe\0/d\0/c\0exit 7");
|
||||
}
|
||||
Reference in new issue
Block a user