Compare commits

..
7 Commits
Author SHA1 Message Date
TomZz ff4079a60f bump version to 0.5.1 2026-08-29 22:49:18 +08:00
TomZz 9d0868dd7c Merge pull request #116 from realmx/fix/terminal-tab-activity-url-boundaries
修复终端标签活动状态与 URL 高亮边界
2026-08-29 22:47:07 +08:00
TomZz f63b8b418c feat(terminal): support switching tabs when focusing adjacent pane at layout boundaries 2026-08-29 22:40:09 +08:00
Realm e1d39d8dc4 fix(terminal): 对齐标签活动状态并修正链接边界
- 标签 Loading 仅响应后台标签最近两秒的真实输出
- 排除 OSC 控制序列并补充终端活动状态回归测试
- 修正 URL 在中文标点和未配对括号后的高亮范围
2026-08-29 19:55:58 +08:00
TomZz c9f722071a build(ci): add dedicated macos-arm64 preview build workflow 2026-08-28 03:41:30 +08:00
TomZz 2e279e206f feat(ui): add keybinding usage tips and shortcut conflict guidance to about page 2026-08-28 03:37:26 +08:00
Realm 7b410afea7 fix(app): resolve connection export modal and terminal history glitches (#115)
- Fix CSV connection export confirmation dialog not opening within settings page
- Remove redundant connect button on connection rows in sidebar edit mode
- Enhance terminal syntax highlighting with background color, inverse, dim, and CJK wide char support
- Improve command history tracking accuracy using buffer-relative coordinates and residue filtering
2026-08-28 03:12:54 +08:00
16 changed files with 734 additions and 240 deletions
+118
View File
@@ -21,6 +21,7 @@ on:
options:
- all
- windows-x64
- macos-arm64
permissions:
contents: write # needed to create / update the GitHub Release
@@ -29,6 +30,7 @@ jobs:
build:
if: >-
github.ref != 'refs/heads/build/windows-x64' &&
github.ref != 'refs/heads/build/macos-arm64' &&
(github.event_name != 'workflow_dispatch' || inputs.platform == 'all')
name: ${{ matrix.name }}
runs-on: ${{ matrix.os }}
@@ -338,6 +340,122 @@ jobs:
if-no-files-found: error
retention-days: 14
build-macos-arm64:
if: >-
github.ref == 'refs/heads/build/macos-arm64' ||
(github.event_name == 'workflow_dispatch' && inputs.platform == 'macos-arm64')
name: macos-arm64
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin
- name: Cache cargo build
uses: Swatinem/rust-cache@v2
with:
key: aarch64-apple-darwin
- name: Build (release)
run: cargo build --release --target aarch64-apple-darwin
- name: Set package metadata
shell: bash
run: |
set -euo pipefail
VERSION="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -n 1)"
if [ -z "$VERSION" ]; then
echo "Unable to read the package version from Cargo.toml" >&2
exit 1
fi
PACKAGE_VERSION="${VERSION}-dev.${GITHUB_SHA:0:7}"
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
echo "PACKAGE_VERSION=$PACKAGE_VERSION" >> "$GITHUB_ENV"
echo "PACKAGE_BASENAME=ashell-v${PACKAGE_VERSION}-macos-arm64" >> "$GITHUB_ENV"
- name: Package macOS app, DMG, and portable archive
shell: bash
run: |
set -euo pipefail
APP_ROOT="$RUNNER_TEMP/${PACKAGE_BASENAME}-app"
DMG_ROOT="$RUNNER_TEMP/${PACKAGE_BASENAME}-dmg"
APP="$APP_ROOT/ashell.app"
CONTENTS="$APP/Contents"
mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources" "$DMG_ROOT" dist
cp "target/aarch64-apple-darwin/release/ashell" "$CONTENTS/MacOS/ashell"
chmod 755 "$CONTENTS/MacOS/ashell"
cp "assets/icons/ashell.icns" "$CONTENTS/Resources/ashell.icns"
cat > "$CONTENTS/Info.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>ashell</string>
<key>CFBundleIconFile</key>
<string>ashell.icns</string>
<key>CFBundleIdentifier</key>
<string>dev.ashell.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>ashell</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>${VERSION}</string>
<key>CFBundleVersion</key>
<string>${GITHUB_RUN_NUMBER}</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>
EOF
printf 'APPL????' > "$CONTENTS/PkgInfo"
plutil -lint "$CONTENTS/Info.plist"
codesign --force --deep --sign - "$APP"
codesign --verify --deep --strict --verbose=2 "$APP"
ditto -c -k --sequesterRsrc --keepParent \
"$APP" "dist/${PACKAGE_BASENAME}-portable.zip"
ditto "$APP" "$DMG_ROOT/ashell.app"
ln -s /Applications "$DMG_ROOT/Applications"
hdiutil create \
-volname "ashell ${VERSION}" \
-srcfolder "$DMG_ROOT" \
-ov \
-format UDZO \
"dist/${PACKAGE_BASENAME}.dmg"
EXPECTED_ARCH="arm64"
ACTUAL_ARCHS="$(lipo -archs "$CONTENTS/MacOS/ashell")"
if [ "$ACTUAL_ARCHS" != "$EXPECTED_ARCH" ]; then
echo "Expected $EXPECTED_ARCH binary, found $ACTUAL_ARCHS" >&2
exit 1
fi
shasum -a 256 dist/*
- name: Upload workflow artifact
uses: actions/upload-artifact@v4
with:
name: macos-arm64
path: dist/*
if-no-files-found: error
retention-days: 14
publish:
name: Publish Release
needs: build
Generated
+1 -1
View File
@@ -315,7 +315,7 @@ dependencies = [
[[package]]
name = "ashell"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"alacritty_terminal",
"anyhow",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ashell"
version = "0.5.0"
version = "0.5.1"
edition = "2024"
authors = ["ashell contributors"]
description = "A GPUI Component based SSH and local terminal client"
+3 -4
View File
@@ -107,10 +107,9 @@ native notification and a flashing bell on the corresponding tab. Unread notific
red badge to the macOS Dock icon or a red overlay to the Windows taskbar icon. Clicking the native
notification activates ashell and switches to the originating terminal tab; viewing that tab clears
its unread state. No reminder is shown when ashell is active and the notification originates in the
currently visible tab. The loading indicator beside a tab title prioritizes OSC 133/633
shell-integration command lifecycle markers. Shells and CLI tools without those markers fall back to
recent terminal output activity, while an OSC 9 completion notification clears the task's loading
state immediately.
currently visible tab. An unselected tab shows a loading indicator beside its title for two seconds
after receiving terminal output. Continued output refreshes the indicator, while the selected tab
does not show it.
You can test a generic OSC 9 notification with:
+2 -3
View File
@@ -106,9 +106,8 @@ OSC 9 携带通知正文;OSC 777 和 OSC 99 可以同时携带标题与正文
符合显示时机的通知来自后台或非当前可见标签时,ashell 会显示系统通知,标签按钮以
闪动铃铛标记未读状态,并在 macOS Dock 或 Windows 任务栏图标上显示红色徽标。点击
系统通知会激活 ashell 并切换到对应的终端标签,查看对应标签后自动清除未读状态。
如果应用已激活且通知来自当前可见标签,则不重复提醒。标签标题左侧的 Loading 动画
优先识别 OSC 133/633 shell integration 的命令开始和结束标记;未提供协议标记时根据
近期终端输出活动回退显示。
如果应用已激活且通知来自当前可见标签,则不重复提醒。未选中的标签在最近 2 秒内收到
终端输出时,会在标题左侧显示 Loading 动画;持续输出会刷新显示时间,当前标签不会显示。
可以分别使用下面的命令测试四种提醒:
+4
View File
@@ -262,6 +262,10 @@ scroll: "Scroll ↓"
clone: "Clone"
about_feedback_hint: "If you encounter a problem or have a suggestion, please report it on our GitHub project."
about_description: "A GPUI Component based SSH and local terminal client"
about_tips_title: "Shortcuts & Usage Tips:"
about_tip_backspace: "Unbinding Shortcuts: Press Backspace while recording a shortcut in Settings -> Keybindings to clear/unbind it."
about_tip_close_shortcuts: "Default Close Shortcuts: macOS uses ⌘+Q (Quit App) / ⌘+W (Close Tab); Windows/Linux uses Alt+F4 or Ctrl+Q (Quit App) / Ctrl+W (Close Tab)."
about_tip_conflict_shortcuts: "Potential Shortcut Conflicts: On Linux/Windows, default Ctrl shortcuts (e.g. Ctrl+W to close tab, Ctrl+L to focus pane) will intercept terminal Shell bindings (e.g. Ctrl+W to backward-kill-word, Ctrl+L to clear screen). Rebind to Ctrl+Shift+W or press Backspace to unbind in Settings -> Keybindings."
version: "Version"
close: "Close"
menu_about_ashell: "About Ashell"
+4
View File
@@ -262,6 +262,10 @@ scroll: "滑动查看 ↓"
clone: "克隆"
about_feedback_hint: "如果遇到问题或者有什么建议,请前往我们的 GitHub 项目中反馈。"
about_description: "基于 GPUI Component 的 SSH 与本地终端客户端"
about_tips_title: "快捷键与使用提示:"
about_tip_backspace: "快捷键解绑:在“设置 -> 快捷键”中录制按键时按 Backspace (退格键) 即可置空/解绑该快捷键。"
about_tip_close_shortcuts: "默认关闭快捷键:macOS 为 ⌘+Q (退出应用) / ⌘+W (关闭标签);Windows / Linux 为 Alt+F4 或 Ctrl+Q (退出应用) / Ctrl+W (关闭标签)。"
about_tip_conflict_shortcuts: "潜在快捷键冲突:Linux / Windows 默认修饰键为 Ctrl,应用默认快捷键(如 Ctrl+W 关闭标签、Ctrl+L 切换窗格等)会挡住终端 Shell 的原生快捷键(如 Bash/Zsh 中 Ctrl+W 清除单词、Ctrl+L 清屏)。建议在“设置 -> 快捷键”中改为 Ctrl+Shift+W 或按 Backspace 置空解绑。"
version: "版本"
close: "关闭"
menu_about_ashell: "关于 Ashell"
+4 -3
View File
@@ -632,7 +632,8 @@ impl Ashell {
}
pub(crate) fn export_connections(&mut self, window: &mut gpui::Window, cx: &mut Context<Self>) {
if self.active_dialog.is_some() {
let parent_dialog = self.active_dialog;
if parent_dialog.is_some() && parent_dialog != Some(DialogKind::Settings) {
return;
}
@@ -695,7 +696,7 @@ impl Ashell {
let sessions = sessions_for_export.clone();
view_for_ok.update(cx, |this, cx| {
if this.active_dialog == Some(DialogKind::ConnectionExport) {
this.active_dialog = None;
this.active_dialog = parent_dialog;
}
this.start_connection_csv_export(sessions, window, cx);
cx.notify();
@@ -707,7 +708,7 @@ impl Ashell {
move |_, _, cx| {
view.update(cx, |this, cx| {
if this.active_dialog == Some(DialogKind::ConnectionExport) {
this.active_dialog = None;
this.active_dialog = parent_dialog;
}
cx.notify();
});
+90
View File
@@ -3285,6 +3285,51 @@ impl Ashell {
);
}
}),
)
.child(
v_flex()
.w_full()
.mt_2()
.p_3()
.gap_2()
.rounded_md()
.bg(cx.theme().secondary.opacity(0.4))
.border_1()
.border_color(cx.theme().border)
.child(
div()
.text_size(ui_rems(0.85))
.font_weight(FontWeight::BOLD)
.text_color(cx.theme().foreground)
.child(t!("about_tips_title")),
)
.child(
v_flex()
.gap_2()
.text_size(ui_rems(0.8))
.text_color(cx.theme().muted_foreground)
.child(
h_flex()
.gap_2()
.items_start()
.child(div().flex_none().child("•"))
.child(div().flex_1().min_w(px(0.)).child(t!("about_tip_backspace"))),
)
.child(
h_flex()
.gap_2()
.items_start()
.child(div().flex_none().child("•"))
.child(div().flex_1().min_w(px(0.)).child(t!("about_tip_close_shortcuts"))),
)
.child(
h_flex()
.gap_2()
.items_start()
.child(div().flex_none().child("•"))
.child(div().flex_1().min_w(px(0.)).child(t!("about_tip_conflict_shortcuts"))),
),
),
),
)
})
@@ -4429,6 +4474,51 @@ impl Ashell {
let _ = open::that("https://github.com/rust-kotlin/ashell");
}),
)
.child(
v_flex()
.w_full()
.mt_3()
.p_3()
.gap_2()
.rounded_md()
.bg(cx.theme().secondary.opacity(0.4))
.border_1()
.border_color(cx.theme().border)
.child(
div()
.text_size(ui_rems(0.85))
.font_weight(FontWeight::BOLD)
.text_color(cx.theme().foreground)
.child(t!("about_tips_title")),
)
.child(
v_flex()
.gap_2()
.text_size(ui_rems(0.8))
.text_color(cx.theme().muted_foreground)
.child(
h_flex()
.gap_2()
.items_start()
.child(div().flex_none().child("•"))
.child(div().flex_1().min_w(px(0.)).child(t!("about_tip_backspace"))),
)
.child(
h_flex()
.gap_2()
.items_start()
.child(div().flex_none().child("•"))
.child(div().flex_1().min_w(px(0.)).child(t!("about_tip_close_shortcuts"))),
)
.child(
h_flex()
.gap_2()
.items_start()
.child(div().flex_none().child("•"))
.child(div().flex_1().min_w(px(0.)).child(t!("about_tip_conflict_shortcuts"))),
),
),
)
}))
)
)
+1 -1
View File
@@ -1565,7 +1565,7 @@ impl Ashell {
continue;
}
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
tab.clear_command_activity();
tab.clear_terminal_activity();
tab.connected = false;
tab.status = reason.clone();
tab.disconnected_reason = Some(reason.clone());
+8 -36
View File
@@ -3336,7 +3336,6 @@ impl Ashell {
active_session_id: Option<&str>,
cx: &mut Context<Self>,
) -> gpui::AnyElement {
let connect_id = session.id.clone();
let edit_id = session.id.clone();
let delete_id = session.id.clone();
let management_mode = self.connection_management_mode;
@@ -3347,8 +3346,6 @@ impl Ashell {
let selection_id = session.id.clone();
let row_selection_id = selection_id.clone();
let row_id = ElementId::Name(format!("saved-connect-{}", session.id).into());
let connect_button_id =
ElementId::Name(format!("connect-saved-session-{}", session.id).into());
div()
.id(row_id)
@@ -3477,33 +3474,7 @@ impl Ashell {
.text_size(ui_rems(0.75))
.text_color(cx.theme().muted_foreground)
.child(detail),
)
.when(management_mode, |this| {
this.child(
h_flex()
.w(px(24.))
.ml_1()
.flex_none()
.items_center()
.justify_center()
.on_mouse_down(MouseButton::Left, |_, _, cx| {
cx.stop_propagation();
})
.on_mouse_down(MouseButton::Right, |_, _, cx| {
cx.stop_propagation();
})
.child(
pointer_button(connect_button_id)
.ghost()
.small()
.icon(IconName::ExternalLink)
.tooltip(t!("connect").to_string())
.on_click(cx.listener(move |this, _, window, cx| {
this.connect_saved_session(connect_id.clone(), window, cx);
})),
),
)
}),
),
)
.into_any_element()
}
@@ -4380,12 +4351,13 @@ impl Ashell {
pane_ids.iter().any(|id| {
self.unread_terminal_notifications.contains(id)
});
let output_active = pane_ids.iter().any(|id| {
self.tabs
.iter()
.find(|tab| tab.id == *id)
.is_some_and(TerminalTab::is_command_active)
});
let output_active = ix != selected
&& pane_ids.iter().any(|id| {
self.tabs
.iter()
.find(|tab| tab.id == *id)
.is_some_and(TerminalTab::has_recent_output)
});
h_flex()
.id(("ashell-tab", ix))
.relative()
+29 -4
View File
@@ -2293,9 +2293,6 @@ impl Ashell {
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.focused_pane_path.is_empty() {
return;
}
let previous_active_tab = self.active_tab.clone();
let mut active_tab_changed = false;
let path = self.focused_pane_path.clone();
@@ -2319,6 +2316,28 @@ impl Ashell {
}
}
cx.notify();
} else if (direction == "left" || direction == "right") && self.tab_groups.len() > 1 {
if let Some(current_group_id) = self.active_group.clone() {
if let Some(current_idx) = self
.tab_groups
.iter()
.position(|g| g.id == current_group_id)
{
let total_groups = self.tab_groups.len();
let target_idx = if direction == "left" {
if current_idx > 0 {
current_idx - 1
} else {
total_groups - 1
}
} else {
(current_idx + 1) % total_groups
};
let target_group_id = self.tab_groups[target_idx].id.clone();
self.activate_group(target_group_id, window, cx);
return;
}
}
}
if active_tab_changed {
self.update_terminal_focus(previous_active_tab.as_deref());
@@ -2446,7 +2465,13 @@ impl Ashell {
}
}
// Load new group state
if let Some(group) = self.tab_groups.iter().find(|g| g.id == group_id) {
if let Some((index, group)) = self
.tab_groups
.iter()
.enumerate()
.find(|(_, g)| g.id == group_id)
{
self.tabs_scroll_handle.scroll_to_item(index);
self.pane_root = group.pane_root.clone();
self.active_group = Some(group_id);
if let Some(first_id) = group.pane_root.first_tab_id() {
+129 -40
View File
@@ -12,6 +12,7 @@ use gpui_component::ActiveTheme as _;
use crate::Ashell;
use crate::terminal::custom_blocks::{is_custom_block_supported, paint_custom_block};
use crate::terminal::highlight::HighlightStyle;
use crate::terminal::{RenderSnapshot, ViewportSelection};
#[derive(Clone, Copy)]
@@ -338,21 +339,16 @@ impl TerminalElement {
}
}
fn cell_run_style(&self, cell: &alacritty_terminal::term::cell::Cell, cx: &App) -> TextRun {
let mut fg = color_to_hsla(cell.fg, true, cx);
let mut bg = color_to_hsla(cell.bg, false, cx);
if cell.flags.contains(Flags::INVERSE) {
std::mem::swap(&mut fg, &mut bg);
}
if cell.flags.contains(Flags::DIM) {
fg.a *= 0.7;
}
fn cell_run_style(
&self,
cell: &alacritty_terminal::term::cell::Cell,
foreground: Hsla,
) -> TextRun {
let underline = cell
.flags
.intersects(Flags::ALL_UNDERLINES)
.then(|| UnderlineStyle {
color: Some(fg),
color: Some(foreground),
thickness: px(1.0),
wavy: cell.flags.contains(Flags::UNDERCURL),
});
@@ -360,7 +356,7 @@ impl TerminalElement {
.flags
.contains(Flags::STRIKEOUT)
.then(|| StrikethroughStyle {
color: Some(fg),
color: Some(foreground),
thickness: px(1.0),
});
@@ -377,7 +373,7 @@ impl TerminalElement {
TextRun {
len: cell.c.len_utf8(),
color: fg,
color: foreground,
background_color: None,
font: Font {
family: self.font_family.clone(),
@@ -408,12 +404,6 @@ impl TerminalElement {
let mut underlines = Vec::new();
let mut current_run: Option<BatchedTextRun> = None;
// Retrieve cached keyword highlights and merge with search highlights
let mut highlights = self.snapshot.highlights.clone();
if let Some(sm) = self.search_highlights.as_ref() {
highlights.extend(sm.iter().map(|(k, v)| (*k, *v)));
}
for render_cell in &self.snapshot.cells {
let cell = &render_cell.cell;
if cell.flags.intersects(
@@ -425,24 +415,54 @@ impl TerminalElement {
let selected = self.snapshot.selection.is_some_and(|selection| {
selection_contains(selection, render_cell.row, render_cell.col)
});
let bg = color_to_hsla(cell.bg, false, cx);
if selected || !is_default_bg(cell.bg) || cell.flags.contains(Flags::INVERSE) {
let cell_position = (render_cell.row, render_cell.col);
let keyword_highlight = self.snapshot.highlights.get(&cell_position).copied();
let search_highlight = self
.search_highlights
.as_ref()
.and_then(|highlights| highlights.get(&cell_position))
.copied();
let (foreground, background) = resolve_cell_colors(
color_to_hsla(cell.fg, true, cx),
color_to_hsla(cell.bg, false, cx),
keyword_highlight,
cell.flags,
);
let cell_span = if cell.flags.contains(Flags::WIDE_CHAR) {
2
} else {
1
};
if selected {
rects.push(LayoutRect {
row: render_cell.row,
col: render_cell.col,
cells: if cell.flags.contains(Flags::WIDE_CHAR) {
2
} else {
1
},
color: if selected {
cx.theme().selection
} else if cell.flags.contains(Flags::INVERSE) {
color_to_hsla(cell.fg, true, cx)
} else {
bg
},
cells: cell_span,
color: cx.theme().selection,
});
} else {
let has_highlight_background =
keyword_highlight.is_some_and(|style| style.background.is_some());
if !is_default_bg(cell.bg)
|| cell.flags.contains(Flags::INVERSE)
|| has_highlight_background
{
rects.push(LayoutRect {
row: render_cell.row,
col: render_cell.col,
cells: cell_span,
color: background,
});
}
if let Some(search_background) = search_highlight {
rects.push(LayoutRect {
row: render_cell.row,
col: render_cell.col,
cells: cell_span,
color: search_background,
});
}
}
if is_blank(cell) {
@@ -452,12 +472,7 @@ impl TerminalElement {
continue;
}
let mut style = self.cell_run_style(cell, cx);
// Apply keyword highlight color if this cell was matched.
if let Some(&hl_color) = highlights.get(&(render_cell.row, render_cell.col)) {
style.color = hl_color;
}
let style = self.cell_run_style(cell, foreground);
// Apply hover underline if mouse is hovering over this URL
if let Some(hu) = &hovered_url {
@@ -871,6 +886,31 @@ fn selection_contains(selection: ViewportSelection, row: i32, col: i32) -> bool
after_start && before_end
}
fn resolve_cell_colors(
mut foreground: Hsla,
mut background: Hsla,
highlight: Option<HighlightStyle>,
flags: Flags,
) -> (Hsla, Hsla) {
if let Some(highlight) = highlight {
if let Some(highlight_foreground) = highlight.foreground {
foreground = highlight_foreground;
}
if let Some(highlight_background) = highlight.background {
background = highlight_background;
}
}
if flags.contains(Flags::INVERSE) {
std::mem::swap(&mut foreground, &mut background);
}
if flags.contains(Flags::DIM) {
foreground.a *= 0.7;
}
(foreground, background)
}
fn is_blank(cell: &alacritty_terminal::term::cell::Cell) -> bool {
cell.c == ' '
&& cell.zerowidth().is_none()
@@ -965,7 +1005,14 @@ fn named_color(named: NamedColor, _foreground: bool, cx: &App) -> Hsla {
#[cfg(test)]
mod tests {
use super::should_render_cursor;
use super::{resolve_cell_colors, should_render_cursor};
use crate::terminal::highlight::HighlightStyle;
use alacritty_terminal::term::cell::Flags;
use gpui::Hsla;
fn color(h: f32, s: f32, l: f32) -> Hsla {
Hsla { h, s, l, a: 1.0 }
}
#[test]
fn renders_cursor_only_for_focused_pane_in_focused_terminal_window() {
@@ -974,4 +1021,46 @@ mod tests {
assert!(!should_render_cursor(true, false, true));
assert!(!should_render_cursor(true, true, false));
}
#[test]
fn applies_highlight_channels_before_inverse_terminal_style() {
let original_foreground = color(0.0, 0.0, 0.8);
let original_background = color(0.0, 0.0, 0.1);
let highlight_foreground = color(0.1, 0.8, 0.5);
let highlight_background = color(0.6, 0.8, 0.4);
let (foreground, background) = resolve_cell_colors(
original_foreground,
original_background,
Some(HighlightStyle {
foreground: Some(highlight_foreground),
background: Some(highlight_background),
}),
Flags::INVERSE,
);
assert_eq!(foreground, highlight_background);
assert_eq!(background, highlight_foreground);
}
#[test]
fn foreground_highlight_preserves_background_and_dim_style() {
let original_foreground = color(0.0, 0.0, 0.8);
let original_background = color(0.0, 0.0, 0.1);
let highlight_foreground = color(0.1, 0.8, 0.5);
let (foreground, background) = resolve_cell_colors(
original_foreground,
original_background,
Some(HighlightStyle {
foreground: Some(highlight_foreground),
background: None,
}),
Flags::DIM,
);
assert_eq!(foreground.alpha(1.0), highlight_foreground);
assert!((foreground.a - 0.7).abs() < f32::EPSILON);
assert_eq!(background, original_background);
}
}
+154 -82
View File
@@ -1,41 +1,19 @@
use crate::terminal::RenderCell;
use gpui::Hsla;
use gpui::{Hsla, Rgba};
use std::collections::HashMap;
trait HslaExt {
fn into_rgba_like(self, r: u8, g: u8, b: u8) -> Self;
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct HighlightStyle {
pub foreground: Option<Hsla>,
pub background: Option<Hsla>,
}
impl HslaExt for Hsla {
fn into_rgba_like(self, r: u8, g: u8, b: u8) -> Self {
let rf = r as f32 / 255.0;
let gf = g as f32 / 255.0;
let bf = b as f32 / 255.0;
let max = rf.max(gf).max(bf);
let min = rf.min(gf).min(bf);
let l = (max + min) / 2.0;
if max == min {
return Hsla {
h: 0.0,
s: 0.0,
l,
a: 1.0,
};
impl HighlightStyle {
fn from_foreground(color: Hsla) -> Self {
Self {
foreground: Some(color),
background: None,
}
let d = max - min;
let s = if l > 0.5 {
d / (2.0 - max - min)
} else {
d / (max + min)
};
let h = if max == rf {
((gf - bf) / d + if gf < bf { 6.0 } else { 0.0 }) / 6.0
} else if max == gf {
((bf - rf) / d + 2.0) / 6.0
} else {
((rf - gf) / d + 4.0) / 6.0
};
Hsla { h, s, l, a: 1.0 }
}
}
@@ -97,13 +75,12 @@ struct HighlightColors {
}
fn hsla(r: u8, g: u8, b: u8) -> Hsla {
Hsla {
h: 0.0,
s: 0.0,
l: 0.0,
Hsla::from(Rgba {
r: r as f32 / 255.0,
g: g as f32 / 255.0,
b: b as f32 / 255.0,
a: 1.0,
}
.into_rgba_like(r, g, b)
})
}
fn highlight_colors() -> HighlightColors {
@@ -168,7 +145,7 @@ fn highlight_colors() -> HighlightColors {
struct KeywordMatch {
start_col: i32,
end_col: i32,
color: Hsla,
style: HighlightStyle,
priority: usize,
}
@@ -184,7 +161,7 @@ impl KeywordMatch {
#[derive(Default)]
struct KeywordHighlights {
colors: HashMap<(i32, i32), Hsla>,
styles: HashMap<(i32, i32), HighlightStyle>,
pending: Vec<KeywordMatch>,
next_priority: usize,
}
@@ -214,7 +191,7 @@ impl KeywordHighlights {
for candidate in selected {
for col in candidate.start_col..=candidate.end_col {
self.colors.insert((row_i32, col), candidate.color);
self.styles.insert((row_i32, col), candidate.style);
}
}
}
@@ -262,7 +239,7 @@ fn highlight_keywords(
map.pending.push(KeywordMatch {
start_col,
end_col,
color,
style: HighlightStyle::from_foreground(color),
priority,
});
start = abs + kw.len();
@@ -276,7 +253,7 @@ fn highlight_keywords(
/// Highlight HTTP status codes (200, 301, 404, 500, etc.)
/// Only matches specific common HTTP codes, not all 3-digit numbers.
fn highlight_http_codes(
map: &mut HashMap<(i32, i32), Hsla>,
map: &mut HashMap<(i32, i32), HighlightStyle>,
text: &str,
byte_to_col: &[i32],
row_i32: i32,
@@ -352,12 +329,13 @@ fn highlight_http_codes(
let start_col = byte_to_col[i];
let end_col = byte_to_col[(i + 2).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.entry((row_i32, c)).or_insert(color);
map.entry((row_i32, c))
.or_insert_with(|| HighlightStyle::from_foreground(color));
}
}
}
pub fn highlight_cells(cells: &[RenderCell], rows: usize) -> HashMap<(i32, i32), Hsla> {
pub fn highlight_cells(cells: &[RenderCell], rows: usize) -> HashMap<(i32, i32), HighlightStyle> {
let colors = highlight_colors();
let mut row_chars: Vec<Vec<(i32, char)>> = vec![Vec::with_capacity(128); rows];
@@ -365,7 +343,9 @@ pub fn highlight_cells(cells: &[RenderCell], rows: usize) -> HashMap<(i32, i32),
if rc.row < 0 || (rc.row as usize) >= rows {
continue;
}
row_chars[rc.row as usize].push((rc.col, rc.cell.c));
if let Some(character) = rendered_text_character(&rc.cell) {
row_chars[rc.row as usize].push((rc.col, character));
}
}
for row in row_chars.iter_mut() {
row.sort_by_key(|&(col, _)| col);
@@ -852,7 +832,7 @@ pub fn highlight_cells(cells: &[RenderCell], rows: usize) -> HashMap<(i32, i32),
map.apply_row(row_i32);
// ── 30. HTTP status codes ──────────────────────────────
highlight_http_codes(&mut map.colors, text, &byte_to_col, row_i32, &colors);
highlight_http_codes(&mut map.styles, text, &byte_to_col, row_i32, &colors);
// ── 31. IP addresses ───────────────────────────────────
for m in find_ip_addresses(text) {
@@ -860,7 +840,9 @@ pub fn highlight_cells(cells: &[RenderCell], rows: usize) -> HashMap<(i32, i32),
let start_col = byte_to_col[m];
let end_col = byte_to_col[(m + ip_len - 1).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.colors.entry((row_i32, c)).or_insert(colors.network);
map.styles
.entry((row_i32, c))
.or_insert_with(|| HighlightStyle::from_foreground(colors.network));
}
}
@@ -870,7 +852,9 @@ pub fn highlight_cells(cells: &[RenderCell], rows: usize) -> HashMap<(i32, i32),
let start_col = byte_to_col[m];
let end_col = byte_to_col[(m + port_len - 1).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.colors.entry((row_i32, c)).or_insert(colors.port);
map.styles
.entry((row_i32, c))
.or_insert_with(|| HighlightStyle::from_foreground(colors.port));
}
}
}
@@ -884,13 +868,15 @@ pub fn highlight_cells(cells: &[RenderCell], rows: usize) -> HashMap<(i32, i32),
let idx = m + i;
if idx < line.byte_to_cell.len() {
let (r, c) = line.byte_to_cell[idx];
map.colors.entry((r as i32, c as i32)).or_insert(colors.url);
map.styles
.entry((r as i32, c as i32))
.or_insert_with(|| HighlightStyle::from_foreground(colors.url));
}
}
}
}
map.colors
map.styles
}
fn find_ip_len(text: &str) -> usize {
@@ -967,32 +953,69 @@ fn find_urls(text: &str) -> Vec<usize> {
}
fn find_url_len(text: &str) -> usize {
let end = text
.find(|c: char| c.is_ascii_whitespace())
.unwrap_or(text.len());
let mut url = &text[..end];
loop {
let Some(&closing) = url.as_bytes().last() else {
break;
};
let opening = match closing {
b')' => b'(',
b']' => b'[',
b'}' => b'{',
_ => break,
};
let opening_count = url.bytes().filter(|byte| *byte == opening).count();
let closing_count = url.bytes().filter(|byte| *byte == closing).count();
if closing_count <= opening_count {
let mut end = text.len();
let mut expected_closings = Vec::new();
for (index, character) in text.char_indices() {
if is_url_terminator(character) {
end = index;
break;
}
url = &url[..url.len() - 1];
match character {
'(' => expected_closings.push(')'),
'[' => expected_closings.push(']'),
'{' => expected_closings.push('}'),
')' | ']' | '}' => {
if expected_closings.last() == Some(&character) {
expected_closings.pop();
} else {
end = index;
break;
}
}
_ => {}
}
}
let mut url = &text[..end];
while let Some(character) = url.chars().next_back() {
if !matches!(character, ',' | '.' | ';' | ':' | '!' | '?') {
break;
}
url = &url[..url.len() - character.len_utf8()];
}
url.len()
}
fn is_url_terminator(character: char) -> bool {
character.is_whitespace()
|| matches!(
character,
'"' | '\''
| '`'
| '<'
| '>'
| ','
| '。'
| ';'
| ':'
| '!'
| '?'
| '、'
| '('
| ')'
| '【'
| '】'
| '《'
| '》'
| '“'
| '”'
| '‘'
| '’'
)
}
fn find_ports(text: &str) -> Vec<usize> {
let mut positions = Vec::new();
let bytes = text.as_bytes();
@@ -1090,10 +1113,12 @@ pub fn build_logical_lines<'a>(cells: &'a [RenderCell], rows: usize) -> Vec<Logi
});
for rc in row_cells {
line.text.push(rc.cell.c);
let end_len = line.text.len();
while line.byte_to_cell.len() < end_len {
line.byte_to_cell.push((rc.row as usize, rc.col as usize));
if let Some(character) = rendered_text_character(&rc.cell) {
line.text.push(character);
let end_len = line.text.len();
while line.byte_to_cell.len() < end_len {
line.byte_to_cell.push((rc.row as usize, rc.col as usize));
}
}
line.row_cells.push(rc);
}
@@ -1108,6 +1133,21 @@ pub fn build_logical_lines<'a>(cells: &'a [RenderCell], rows: usize) -> Vec<Logi
logical_lines
}
fn rendered_text_character(cell: &alacritty_terminal::term::cell::Cell) -> Option<char> {
use alacritty_terminal::term::cell::Flags;
if cell
.flags
.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER)
{
None
} else if cell.flags.contains(Flags::HIDDEN) {
Some(' ')
} else {
Some(cell.c)
}
}
pub fn find_url_at_cell(
cells: &[RenderCell],
rows: usize,
@@ -1142,7 +1182,7 @@ pub fn find_url_at_cell(
#[cfg(test)]
mod tests {
use super::{KeywordHighlights, KeywordMatch, find_url_len, hsla};
use super::{HighlightStyle, KeywordHighlights, KeywordMatch, find_url_len, hsla};
fn detected_url(text: &str) -> &str {
&text[..find_url_len(text)]
@@ -1172,6 +1212,28 @@ mod tests {
);
}
#[test]
fn stops_before_text_after_an_unmatched_closing_delimiter() {
assert_eq!(
detected_url(
"https://github.com/gnachman/iTerm2/blob/master/sources/PTYSession/PTYSession.m#L6371-L6380)、PTYTab.m"
),
"https://github.com/gnachman/iTerm2/blob/master/sources/PTYSession/PTYSession.m#L6371-L6380"
);
}
#[test]
fn excludes_chinese_and_ascii_trailing_punctuation() {
assert_eq!(
detected_url("https://example.com/path、后续文字"),
"https://example.com/path"
);
assert_eq!(
detected_url("https://example.com/path, next"),
"https://example.com/path"
);
}
#[test]
fn overlapping_keywords_keep_the_longest_match_color() {
let long_color = hsla(10, 20, 30);
@@ -1181,21 +1243,26 @@ mod tests {
KeywordMatch {
start_col: 3,
end_col: 11,
color: short_color,
style: HighlightStyle::from_foreground(short_color),
priority: 0,
},
KeywordMatch {
start_col: 0,
end_col: 11,
color: long_color,
style: HighlightStyle::from_foreground(long_color),
priority: 1,
},
];
highlights.apply_row(0);
assert_eq!(highlights.colors.len(), 12);
assert!(highlights.colors.values().all(|color| *color == long_color));
assert_eq!(highlights.styles.len(), 12);
assert!(
highlights
.styles
.values()
.all(|style| style.foreground == Some(long_color) && style.background.is_none())
);
}
#[test]
@@ -1208,8 +1275,13 @@ mod tests {
super::highlight_keywords(&mut highlights, text, &byte_to_col, 0, &["BOOT"], color);
highlights.apply_row(0);
assert_eq!(highlights.colors.len(), 4);
assert!((0..9).all(|col| !highlights.colors.contains_key(&(0, col))));
assert!((10..14).all(|col| highlights.colors.get(&(0, col)) == Some(&color)));
assert_eq!(highlights.styles.len(), 4);
assert!((0..9).all(|col| !highlights.styles.contains_key(&(0, col))));
assert!((10..14).all(|col| {
highlights
.styles
.get(&(0, col))
.is_some_and(|style| style.foreground == Some(color) && style.background.is_none())
}));
}
}
+101 -26
View File
@@ -209,7 +209,7 @@ impl Ashell {
}
tab.clear_selection();
tab.record_terminal_input(&bytes);
tab.prepare_for_terminal_input();
let encoded = tab.encode_input(&bytes);
tab.send_backend(BackendCommand::Input(encoded));
window.prevent_default();
@@ -325,7 +325,7 @@ impl Ashell {
}
tab.clear_selection();
self.terminal_marked_text = None;
tab.record_terminal_input(&bytes);
tab.prepare_for_terminal_input();
let encoded = tab.encode_input(&bytes);
tab.send_backend(BackendCommand::Input(encoded));
window.invalidate_character_coordinates();
@@ -345,7 +345,7 @@ impl Ashell {
};
(
session.id.clone(),
tab.cursor_state().map(|cursor| (cursor.row, cursor.col)),
tab.buffer_cursor_position(),
tab.is_alternate_screen_active(),
)
};
@@ -1284,6 +1284,11 @@ fn terminal_command_text(
start: (usize, usize),
end: Option<(usize, usize)>,
) -> Option<String> {
let start = buffer_position_in_viewport(snapshot, start)?;
let end = match end {
Some(position) => Some(buffer_position_in_viewport(snapshot, position)?),
None => None,
};
let logical_lines =
crate::terminal::highlight::build_logical_lines(&snapshot.cells, snapshot.rows);
for line in logical_lines {
@@ -1315,42 +1320,38 @@ fn terminal_command_text(
None
}
fn buffer_position_in_viewport(
snapshot: &crate::terminal::RenderSnapshot,
position: (usize, usize),
) -> Option<(usize, usize)> {
let viewport_start = snapshot
.history_size
.saturating_sub(snapshot.display_offset);
let row = position.0.checked_sub(viewport_start)?;
(row < snapshot.rows && position.1 < snapshot.cols).then_some((row, position.1))
}
fn command_history_text(rendered: Option<&str>, buffered: &str, input_uncertain: bool) -> String {
let rendered = rendered.unwrap_or_default().trim();
let buffered = buffered.trim();
if !input_uncertain && !buffered.is_empty() {
return buffered.to_string();
}
merge_command_text(rendered, buffered)
}
fn merge_command_text(rendered: Option<&str>, buffered: &str) -> String {
let rendered = rendered.unwrap_or_default().trim();
let buffered = buffered.trim();
if rendered.is_empty() {
return buffered.to_string();
}
if buffered.is_empty() {
return rendered.to_string();
}
if rendered.starts_with(buffered) || rendered.ends_with(buffered) {
return rendered.to_string();
}
if buffered.starts_with(rendered) || buffered.ends_with(rendered) {
// Completion extends the current token; a new argument after exact raw input is stale content.
if rendered
.strip_prefix(buffered)
.and_then(|suffix| suffix.chars().next())
.is_some_and(char::is_whitespace)
{
return buffered.to_string();
}
let overlap = buffered
.char_indices()
.map(|(index, _)| index)
.chain(std::iter::once(buffered.len()))
.filter(|index| *index > 0 && rendered.ends_with(&buffered[..*index]))
.max()
.unwrap_or(0);
if overlap > 0 {
format!("{rendered}{}", &buffered[overlap..])
} else {
rendered.to_string()
}
rendered.to_string()
}
#[cfg(test)]
@@ -1581,6 +1582,58 @@ mod tests {
);
}
#[test]
fn rejects_whitespace_delimited_screen_suffix_when_input_is_uncertain() {
let command = "sh /site/jimureport/jimureport-restart.sh";
let rendered = format!("{command} /sijiji-r");
assert_eq!(
command_history_text(Some(rendered.as_str()), command, true),
command
);
}
#[test]
fn does_not_append_tab_completion_keystroke_fragments() {
let command = "sh /site/jimureport/jimureport-restart.sh";
let buffered = "sh /sijiji-r";
assert_eq!(command_history_text(Some(command), buffered, true), command);
}
#[test]
fn falls_back_to_raw_input_when_uncertain_screen_text_is_unavailable() {
let command = "sh /site/jimureport/jimureport-restart.sh";
assert_eq!(command_history_text(None, command, true), command);
}
#[test]
fn keeps_screen_completion_that_extends_the_current_argument() {
let buffered = "sh /site/jimu";
let rendered = "sh /site/jimureport/jimureport-restart.sh";
assert_eq!(
command_history_text(Some(rendered), buffered, true),
rendered
);
}
#[test]
fn excludes_hidden_screen_residue_from_rendered_command() {
let command = "sh /site/jimureport/jimureport-restart.sh";
let rendered = format!("$ {command} /sijiji-r");
let mut snapshot = snapshot(&[rendered.as_str()], rendered.len());
for cell in snapshot.cells.iter_mut().skip(2 + command.chars().count()) {
cell.cell.flags.insert(Flags::HIDDEN);
}
assert_eq!(
terminal_command_text(&snapshot, (0, 2), None),
Some(command.to_string())
);
}
#[test]
fn truncates_rendered_command_at_the_submission_cursor() {
let command = "sh /site/vocano/vocano-restart.sh";
@@ -1592,4 +1645,26 @@ mod tests {
Some(command.to_string())
);
}
#[test]
fn keeps_command_coordinates_stable_when_wrapping_scrolls_the_viewport() {
let first_row = "$ sh /site/jimureport/jimureport-";
let second_row = "restart.sh /sijiji-r";
let mut snapshot = snapshot(&[first_row, second_row], first_row.len());
snapshot.history_size = 3;
snapshot.display_offset = 2;
snapshot
.cells
.iter_mut()
.find(|cell| cell.row == 0 && cell.col == first_row.len() as i32 - 1)
.unwrap()
.cell
.flags
.insert(Flags::WRAPLINE);
assert_eq!(
terminal_command_text(&snapshot, (1, 2), Some((2, "restart.sh".len()))),
Some("sh /site/jimureport/jimureport-restart.sh".to_string())
);
}
}
+85 -39
View File
@@ -37,7 +37,7 @@ pub enum TabKind {
Serial,
}
const TERMINAL_ACTIVITY_GRACE: Duration = Duration::from_millis(750);
const TERMINAL_ACTIVITY_GRACE: Duration = Duration::from_secs(2);
const CLICK_CURSOR_PREDICTION_TTL: Duration = Duration::from_millis(750);
const MAX_OSC_PAYLOAD_BYTES: usize = 4096;
const MAX_NOTIFICATION_TEXT_BYTES: usize = 8192;
@@ -242,27 +242,39 @@ struct OscTerminalParser {
pending_osc99: HashMap<String, PendingOsc99Notification>,
}
struct OscTerminalScan {
events: Vec<(usize, OscTerminalEvent)>,
has_terminal_output: bool,
}
impl OscTerminalParser {
/// Scans decoded terminal output without consuming it from the terminal emulator.
#[cfg(test)]
fn advance(&mut self, bytes: &[u8]) -> Vec<OscTerminalEvent> {
self.advance_with_offsets(bytes)
.events
.into_iter()
.map(|(_, event)| event)
.collect()
}
fn advance_with_offsets(&mut self, bytes: &[u8]) -> Vec<(usize, OscTerminalEvent)> {
fn advance_with_offsets(&mut self, bytes: &[u8]) -> OscTerminalScan {
let mut events = Vec::new();
let mut has_terminal_output = false;
for (index, &byte) in bytes.iter().enumerate() {
match self.state {
OscTerminalState::Ground => {
if byte == 0x1b {
self.state = OscTerminalState::Escape;
} else {
has_terminal_output = true;
}
}
OscTerminalState::Escape => {
if byte != b']' && byte != 0x1b {
has_terminal_output = true;
}
self.state = match byte {
b']' => {
self.command.clear();
@@ -333,7 +345,10 @@ impl OscTerminalParser {
}
}
events
OscTerminalScan {
events,
has_terminal_output,
}
}
fn push_payload_byte(&mut self, byte: u8) {
@@ -869,8 +884,6 @@ pub struct TerminalTab {
output_decoder: StreamingDecoder,
osc_terminal_parser: OscTerminalParser,
output_activity_until: Option<Instant>,
command_running: bool,
shell_integration_available: bool,
processor: Processor,
term: Term<TerminalListener>,
pub cols: u16,
@@ -887,7 +900,7 @@ pub struct TerminalTab {
type HighlightCache = std::cell::RefCell<
Option<(
Vec<RenderCell>,
std::collections::HashMap<(i32, i32), gpui::Hsla>,
std::collections::HashMap<(i32, i32), self::highlight::HighlightStyle>,
)>,
>;
@@ -940,7 +953,7 @@ pub struct RenderSnapshot {
pub history_size: usize,
pub rows: usize,
pub cols: usize,
pub highlights: std::collections::HashMap<(i32, i32), gpui::Hsla>,
pub highlights: std::collections::HashMap<(i32, i32), self::highlight::HighlightStyle>,
}
#[derive(Clone, Copy)]
@@ -1283,7 +1296,7 @@ mod terminal_tab_backend_tests {
);
tab.note_click_cursor_move_at(predicted, now);
tab.record_terminal_input(b"x");
tab.prepare_for_terminal_input();
assert_eq!(tab.cursor_state_for_click_at(now), actual);
}
}
@@ -1542,6 +1555,60 @@ mod osc_terminal_tests {
);
}
#[test]
fn distinguishes_terminal_output_from_osc_signaling() {
let mut parser = OscTerminalParser::default();
let shell_marker = parser.advance_with_offsets(b"\x1b]133;C\x07");
assert!(!shell_marker.has_terminal_output);
assert_eq!(
shell_marker.events,
vec![(b"\x1b]133;C\x07".len(), OscTerminalEvent::CommandStarted)]
);
let output = parser.advance_with_offsets(b"server ready\r\n");
assert!(output.has_terminal_output);
assert!(output.events.is_empty());
let mixed = parser.advance_with_offsets(b"built\r\n\x1b]133;D;0\x07");
assert!(mixed.has_terminal_output);
assert_eq!(mixed.events.len(), 1);
}
#[test]
fn excludes_osc_signaling_split_across_output_chunks() {
let mut parser = OscTerminalParser::default();
assert!(
!parser
.advance_with_offsets(b"\x1b]133;")
.has_terminal_output
);
let completed = parser.advance_with_offsets(b"C\x07");
assert!(!completed.has_terminal_output);
assert_eq!(completed.events.len(), 1);
}
#[test]
fn shell_integration_markers_do_not_drive_tab_output_activity() {
let (events_tx, _events_rx) = mpsc::channel();
let mut tab = TerminalTab::new_local(
"tab-1".into(),
"Local".into(),
BackendTx::Pending,
GuardedBackendEventSender::new(events_tx),
);
tab.feed(b"\x1b]133;C\x07");
assert!(!tab.has_recent_output());
tab.feed(b"server ready\r\n");
assert!(tab.has_recent_output());
tab.feed(b"\x1b]133;D;0\x07");
assert!(tab.has_recent_output());
}
#[test]
fn parses_prompt_click_modes_and_preserves_event_offsets() {
let mut parser = OscTerminalParser::default();
@@ -1549,6 +1616,7 @@ mod osc_terminal_tests {
let (event_end, event) = parser
.advance_with_offsets(bytes)
.events
.into_iter()
.next()
.expect("prompt marker event");
@@ -1807,8 +1875,6 @@ impl TerminalTab {
output_decoder: StreamingDecoder::new(TextEncoding::Utf8),
osc_terminal_parser: OscTerminalParser::default(),
output_activity_until: None,
command_running: false,
shell_integration_available: false,
processor: Processor::new(),
term: new_term(100, 30, shared_backend.clone(), id, events.clone()),
cols: 100,
@@ -1825,12 +1891,13 @@ impl TerminalTab {
pub fn feed(&mut self, bytes: &[u8]) -> Vec<TerminalNotification> {
let decoded = self.output_decoder.decode(bytes);
if !decoded.is_empty() {
let scan = self.osc_terminal_parser.advance_with_offsets(&decoded);
if scan.has_terminal_output {
self.output_activity_until = Some(Instant::now() + TERMINAL_ACTIVITY_GRACE);
}
let mut notifications = Vec::new();
let mut processed_until = 0;
for (event_end, event) in self.osc_terminal_parser.advance_with_offsets(&decoded) {
for (event_end, event) in scan.events {
self.processor
.advance(&mut self.term, &decoded[processed_until..event_end]);
self.handle_osc_terminal_event(event, &mut notifications);
@@ -1850,8 +1917,6 @@ impl TerminalTab {
) {
match event {
OscTerminalEvent::Notification(notification) => {
self.command_running = false;
self.output_activity_until = None;
notifications.push(notification);
}
OscTerminalEvent::ProtocolReply(reply) => {
@@ -1862,9 +1927,6 @@ impl TerminalTab {
secondary,
} => {
self.clear_click_cursor_prediction();
self.shell_integration_available = true;
self.command_running = false;
self.output_activity_until = None;
let prompt_start = self.buffer_cursor_position().unwrap_or((0, 0));
if secondary {
if let Some(prompt_input) = self.prompt_input.as_mut() {
@@ -1907,15 +1969,10 @@ impl TerminalTab {
}
OscTerminalEvent::CommandStarted => {
self.clear_click_cursor_prediction();
self.shell_integration_available = true;
self.command_running = true;
self.prompt_input = None;
}
OscTerminalEvent::CommandFinished => {
self.clear_click_cursor_prediction();
self.shell_integration_available = true;
self.command_running = false;
self.output_activity_until = None;
self.prompt_input = None;
}
}
@@ -1944,9 +2001,8 @@ impl TerminalTab {
true
}
pub(crate) fn is_command_active(&self) -> bool {
(self.command_running && !self.is_alternate_screen_active())
|| self.output_activity_until.is_some()
pub(crate) fn has_recent_output(&self) -> bool {
self.output_activity_until.is_some()
}
pub(crate) fn expire_output_activity(&mut self, now: Instant) -> bool {
@@ -1961,22 +2017,13 @@ impl TerminalTab {
}
}
pub(crate) fn record_terminal_input(&mut self, bytes: &[u8]) {
pub(crate) fn prepare_for_terminal_input(&mut self) {
self.clear_click_cursor_prediction();
if self.shell_integration_available
&& !self.is_alternate_screen_active()
&& bytes.iter().any(|byte| matches!(byte, b'\r' | b'\n'))
{
self.command_running = true;
self.output_activity_until = None;
}
}
pub(crate) fn clear_command_activity(&mut self) -> bool {
let changed = self.command_running || self.output_activity_until.is_some();
self.command_running = false;
pub(crate) fn clear_terminal_activity(&mut self) -> bool {
let changed = self.output_activity_until.is_some();
self.output_activity_until = None;
self.shell_integration_available = false;
self.prompt_input = None;
self.clear_click_cursor_prediction();
self.osc_terminal_parser = OscTerminalParser::default();
@@ -2005,7 +2052,6 @@ impl TerminalTab {
self.output_decoder = StreamingDecoder::new(encoding);
self.osc_terminal_parser = OscTerminalParser::default();
self.output_activity_until = None;
self.command_running = false;
self.prompt_input = None;
self.clear_click_cursor_prediction();
if let Some(session) = self.session.as_mut() {
@@ -2136,7 +2182,7 @@ impl TerminalTab {
let _ = self.cursor_state_for_click_at(now);
}
fn buffer_cursor_position(&self) -> Option<(usize, usize)> {
pub(crate) fn buffer_cursor_position(&self) -> Option<(usize, usize)> {
let grid = self.term.grid();
let row = usize::try_from(grid.cursor.point.line.0).ok()?;
Some((