From d8838082dc06b0d85ef164650b979e57a87f7721 Mon Sep 17 00:00:00 2001 From: akbash Date: Tue, 4 Aug 2026 22:26:52 +0300 Subject: [PATCH 1/7] fix(cli): resolve pane query --current from caller (#2298) refs #2297 Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com> --- src/cli/pane.rs | 46 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/src/cli/pane.rs b/src/cli/pane.rs index 7c12e0f9..e7d65702 100644 --- a/src/cli/pane.rs +++ b/src/cli/pane.rs @@ -139,7 +139,7 @@ fn parse_pane_current_args( } fn pane_layout(args: &[String]) -> std::io::Result { - let pane_id = match parse_optional_current_pane_args(args) { + let pane_id = match parse_optional_current_pane_args_from_env(args) { Ok(pane_id) => pane_id, Err(message) => { eprintln!("{message}"); @@ -154,7 +154,7 @@ fn pane_layout(args: &[String]) -> std::io::Result { } fn pane_process_info(args: &[String]) -> std::io::Result { - let pane_id = match parse_optional_current_pane_args(args) { + let pane_id = match parse_optional_current_pane_args_from_env(args) { Ok(pane_id) => pane_id, Err(message) => { eprintln!("{message}"); @@ -169,7 +169,7 @@ fn pane_process_info(args: &[String]) -> std::io::Result { } fn pane_edges(args: &[String]) -> std::io::Result { - let pane_id = match parse_optional_current_pane_args(args) { + let pane_id = match parse_optional_current_pane_args_from_env(args) { Ok(pane_id) => pane_id, Err(message) => { eprintln!("{message}"); @@ -222,7 +222,17 @@ fn pane_resize(args: &[String]) -> std::io::Result { super::runtime::pane_resize(params) } -fn parse_optional_current_pane_args(args: &[String]) -> Result, String> { +fn parse_optional_current_pane_args_from_env(args: &[String]) -> Result, String> { + let env_pane_id = std::env::var("HERDR_PANE_ID") + .ok() + .filter(|value| !value.trim().is_empty()); + parse_optional_current_pane_args(args, env_pane_id.as_deref()) +} + +fn parse_optional_current_pane_args( + args: &[String], + env_pane_id: Option<&str>, +) -> Result, String> { let mut pane_id = None; let mut index = 0; while index < args.len() { @@ -235,7 +245,7 @@ fn parse_optional_current_pane_args(args: &[String]) -> Result, S index += 2; } "--current" => { - pane_id = None; + pane_id = env_pane_id.map(super::normalize_pane_id); index += 1; } other => return Err(format!("unknown option: {other}")), @@ -1734,9 +1744,33 @@ mod tests { assert_eq!(params.direction, PaneDirection::Down); } + #[test] + fn parse_optional_current_pane_args_accepts_current_target() { + let pane_id = + parse_optional_current_pane_args(&args(&["--current"]), Some("issue-1")).unwrap(); + + assert_eq!(pane_id, Some("issue-1".into())); + } + + #[test] + fn parse_optional_current_pane_args_current_without_env_keeps_focused_fallback() { + let pane_id = parse_optional_current_pane_args(&args(&["--current"]), None).unwrap(); + + assert_eq!(pane_id, None); + } + + #[test] + fn parse_optional_current_pane_args_omitted_target_keeps_focused_fallback() { + let pane_id = parse_optional_current_pane_args(&args(&[]), Some("issue-1")).unwrap(); + + assert_eq!(pane_id, None); + } + #[test] fn parse_optional_current_pane_args_accepts_explicit_pane() { - let pane_id = parse_optional_current_pane_args(&args(&["--pane", "issue-2"])).unwrap(); + let pane_id = + parse_optional_current_pane_args(&args(&["--pane", "issue-2"]), Some("issue-1")) + .unwrap(); assert_eq!(pane_id, Some("issue-2".into())); } From ee8429fb79ed0c53d2f23fa1b75b415e7f0cd377 Mon Sep 17 00:00:00 2001 From: akbash Date: Tue, 4 Aug 2026 22:35:33 +0300 Subject: [PATCH 2/7] fix(input): parse default mouse reports (#2312) * fix(input): parse default mouse reports refs #2309 * fix(input): preserve split default mouse reports refs #2309 --------- Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com> Co-authored-by: Can Celik --- src/client/input.rs | 12 +++-- src/raw_input.rs | 112 ++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 111 insertions(+), 13 deletions(-) diff --git a/src/client/input.rs b/src/client/input.rs index fd374491..9e0d300e 100644 --- a/src/client/input.rs +++ b/src/client/input.rs @@ -189,7 +189,7 @@ fn idle_flush_timeout_ms( host_mouse_capture_active: bool, ) -> i32 { if host_mouse_capture_active - && (framer.has_pending_lone_escape() || framer.has_pending_incomplete_sgr_mouse_sequence()) + && (framer.has_pending_lone_escape() || framer.has_pending_incomplete_mouse_sequence()) { crate::raw_input::MOUSE_ACTIVE_ESCAPE_SEQUENCE_FLUSH_TIMEOUT_MS } else { @@ -543,18 +543,20 @@ mod tests { fn mouse_active_escape_sequences_get_longer_reassembly_window() { let mut escape = crate::raw_input::RawInputByteFramer::default(); assert!(escape.push(b"\x1b").is_empty()); - let mut mouse = crate::raw_input::RawInputByteFramer::default(); - assert!(mouse.push(b"\x1b[<3").is_empty()); + let mut sgr_mouse = crate::raw_input::RawInputByteFramer::default(); + assert!(sgr_mouse.push(b"\x1b[<3").is_empty()); + let mut default_mouse = crate::raw_input::RawInputByteFramer::default(); + assert!(default_mouse.push(b"\x1b[MC").is_empty()); let mut unrelated = crate::raw_input::RawInputByteFramer::default(); assert!(unrelated.push(b"\x1b[49:33;2:").is_empty()); - for framer in [&escape, &mouse, &unrelated] { + for framer in [&escape, &sgr_mouse, &default_mouse, &unrelated] { assert_eq!( idle_flush_timeout_ms(framer, false), crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS ); } - for framer in [&escape, &mouse] { + for framer in [&escape, &sgr_mouse, &default_mouse] { assert_eq!( idle_flush_timeout_ms(framer, true), crate::raw_input::MOUSE_ACTIVE_ESCAPE_SEQUENCE_FLUSH_TIMEOUT_MS diff --git a/src/raw_input.rs b/src/raw_input.rs index 5bd57719..12bcba4d 100644 --- a/src/raw_input.rs +++ b/src/raw_input.rs @@ -176,8 +176,8 @@ impl RawInputFramer { self.byte_framer.has_pending_input() } - pub(crate) fn has_pending_incomplete_sgr_mouse_sequence(&self) -> bool { - self.byte_framer.has_pending_incomplete_sgr_mouse_sequence() + pub(crate) fn has_pending_incomplete_mouse_sequence(&self) -> bool { + self.byte_framer.has_pending_incomplete_mouse_sequence() } #[cfg(any(windows, test))] @@ -277,8 +277,9 @@ impl RawInputByteFramer { self.buffer.as_slice() == [ESC] } - pub(crate) fn has_pending_incomplete_sgr_mouse_sequence(&self) -> bool { + pub(crate) fn has_pending_incomplete_mouse_sequence(&self) -> bool { starts_with_incomplete_sgr_mouse_sequence(&self.buffer) + || starts_with_incomplete_default_mouse_sequence(&self.buffer) } #[cfg(any(windows, test))] @@ -586,7 +587,7 @@ pub(crate) fn events_require_host_terminal_theme_query(events: &[RawInputEvent]) } fn input_flush_timeout_ms(framer: &RawInputFramer) -> i32 { - if framer.has_pending_incomplete_sgr_mouse_sequence() { + if framer.has_pending_incomplete_mouse_sequence() { MOUSE_ACTIVE_ESCAPE_SEQUENCE_FLUSH_TIMEOUT_MS } else { RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS @@ -784,6 +785,12 @@ fn extract_one_event(buffer: &[u8]) -> Option<(RawInputEvent, usize)> { if buffer[0] == ESC { let seq_len = complete_escape_sequence_len(buffer)?; + if buffer[..seq_len].starts_with(b"\x1b[M") { + let event = parse_default_mouse(&buffer[..seq_len]) + .map(RawInputEvent::Mouse) + .unwrap_or(RawInputEvent::Unsupported); + return Some((event, seq_len)); + } let seq = std::str::from_utf8(&buffer[..seq_len]).ok()?; if let Some((kind, color)) = parse_default_color_response(seq) { @@ -980,6 +987,13 @@ fn complete_escape_sequence_len(buffer: &[u8]) -> Option { } } + if buffer.len() >= 7 + && buffer.starts_with(b"\x1b\x1b[M") + && parse_default_mouse(&buffer[1..7]).is_some() + { + return Some(1); + } + if buffer.starts_with(b"\x1b\x1b") { return complete_escape_sequence_len(&buffer[1..]).map(|len| len + 1); } @@ -988,6 +1002,9 @@ fn complete_escape_sequence_len(buffer: &[u8]) -> Option { if buffer.starts_with(b"\x1b[<") { return find_csi_final(buffer, b"Mm"); } + if buffer.starts_with(b"\x1b[M") { + return (buffer.len() >= 6).then_some(6); + } return find_csi_final( buffer, b"@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~", @@ -1020,6 +1037,10 @@ fn starts_with_incomplete_sgr_mouse_sequence(buffer: &[u8]) -> bool { .all(|byte| byte.is_ascii_digit() || *byte == b';') } +fn starts_with_incomplete_default_mouse_sequence(buffer: &[u8]) -> bool { + buffer.starts_with(b"\x1b[M") && buffer.len() < 6 +} + fn starts_with_incomplete_orphaned_sgr_mouse_tail(buffer: &[u8]) -> bool { if buffer.len() > MAX_ORPHANED_SGR_MOUSE_TAIL_BYTES { return false; @@ -1166,6 +1187,23 @@ fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option { .position(|window| window == needle) } +fn parse_default_mouse(sequence: &[u8]) -> Option { + let &[ESC, b'[', b'M', encoded_cb, encoded_column, encoded_row] = sequence else { + return None; + }; + let cb = encoded_cb.checked_sub(32)?; + let column = u16::from(encoded_column).checked_sub(33)?; + let row = u16::from(encoded_row).checked_sub(33)?; + let (kind, modifiers) = parse_mouse_cb(cb)?; + + Some(MouseEvent { + kind, + column, + row, + modifiers, + }) +} + fn parse_sgr_mouse(sequence: &str) -> Option { let body = sequence.strip_prefix("\x1b[<")?; let final_char = body.chars().last()?; @@ -1361,6 +1399,28 @@ mod tests { assert_eq!(mouse.modifiers, KeyModifiers::empty()); } + #[test] + fn parses_default_mouse_encoding() { + let events = parse_raw_input_bytes_sync(b"\x1b[MCN1"); + let [RawInputEvent::Mouse(mouse)] = events.as_slice() else { + panic!("expected one mouse event"); + }; + assert_eq!(mouse.kind, MouseEventKind::Moved); + assert_eq!((mouse.column, mouse.row), (45, 16)); + assert_eq!(mouse.modifiers, KeyModifiers::empty()); + } + + #[test] + fn rejected_default_mouse_frame_preserves_trailing_input() { + let events = parse_raw_input_bytes_with_ranges(b"\x1b[M\x82AAx"); + + assert_eq!(events.len(), 2); + assert!(matches!(events[0].event, RawInputEvent::Unsupported)); + assert_eq!((events[0].start, events[0].len), (0, 6)); + assert!(matches!(events[1].event, RawInputEvent::Key(_))); + assert_eq!((events[1].start, events[1].len), (6, 1)); + } + #[test] fn parses_extended_button_drag_as_mouse_motion() { for input in [ @@ -1916,6 +1976,28 @@ mod tests { } } + #[test] + fn lone_escape_then_default_mouse_report_emits_both_events() { + let mut framer = RawInputFramer::default(); + + assert!(framer.push(b"\x1b").is_empty()); + let events = framer.push(b"\x1b[MCN1"); + + assert_eq!(events.len(), 2); + let mut events = events.into_iter(); + assert_raw_key(events.next().unwrap(), KeyCode::Esc, KeyModifiers::empty()); + assert!(matches!( + events.next().unwrap(), + RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Moved, + column: 45, + row: 16, + .. + }) + )); + assert!(framer.flush_timeout().is_empty()); + } + #[test] fn legacy_doubled_escape_alt_arrow_remains_one_event() { let mut framer = RawInputFramer::default(); @@ -1950,14 +2032,28 @@ mod tests { } #[test] - fn legacy_reader_extends_only_incomplete_sgr_mouse_timeout() { - let mut mouse = RawInputFramer::default(); - assert!(mouse.push(b"\x1b[<3").is_empty()); + fn legacy_reader_extends_incomplete_mouse_timeouts() { + let mut sgr_mouse = RawInputFramer::default(); + assert!(sgr_mouse.push(b"\x1b[<3").is_empty()); assert_eq!( - input_flush_timeout_ms(&mouse), + input_flush_timeout_ms(&sgr_mouse), MOUSE_ACTIVE_ESCAPE_SEQUENCE_FLUSH_TIMEOUT_MS ); + let report = b"\x1b[MCN1"; + for split in 3..report.len() { + let mut default_mouse = RawInputFramer::default(); + assert!(default_mouse.push(&report[..split]).is_empty()); + assert_eq!( + input_flush_timeout_ms(&default_mouse), + MOUSE_ACTIVE_ESCAPE_SEQUENCE_FLUSH_TIMEOUT_MS + ); + assert!(matches!( + default_mouse.push(&report[split..]).as_slice(), + [RawInputEvent::Mouse(_)] + )); + } + let mut escape = RawInputFramer::default(); assert!(escape.push(b"\x1b").is_empty()); assert_eq!( From 93e0086f383f000ada02be382230507d5bdf4d7e Mon Sep 17 00:00:00 2001 From: akbash Date: Tue, 4 Aug 2026 23:03:16 +0300 Subject: [PATCH 3/7] fix(ui): search single-tab names in navigator (#2320) --- src/app/actions.rs | 81 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 66 insertions(+), 15 deletions(-) diff --git a/src/app/actions.rs b/src/app/actions.rs index 51f71877..51de049c 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -442,18 +442,25 @@ impl AppState { let multi_tab = ws.tabs.len() > 1; let mut rows = Vec::new(); for tab_idx in 0..ws.tabs.len() { - let mut tab_row = multi_tab.then(|| self.navigator_tab_row(ws_idx, tab_idx)); - let tab_matches = tab_row.as_ref().is_some_and(|row| match query_kind { + let mut tab_row = self.navigator_tab_row(ws_idx, tab_idx); + let tab_matches = match query_kind { NavigatorQueryKind::Empty => true, NavigatorQueryKind::State(filter) => { - navigator_state_filter_matches(filter, row.status, row.seen) + navigator_state_filter_matches(filter, tab_row.status, tab_row.seen) } - NavigatorQueryKind::Text => navigator_matches(query, &row.search_text), - }); - if let Some(tab_row) = tab_row.as_mut() { - tab_row.matched = tab_matches; - } - let mut pane_rows = self.navigator_pane_rows_for_tab(ws_idx, tab_idx, multi_tab); + NavigatorQueryKind::Text => navigator_matches( + query, + if multi_tab { + &tab_row.search_text + } else { + &tab_row.label + }, + ), + }; + tab_row.matched = tab_matches; + let show_tab_row = + multi_tab || (matches!(query_kind, NavigatorQueryKind::Text) && tab_matches); + let mut pane_rows = self.navigator_pane_rows_for_tab(ws_idx, tab_idx, show_tab_row); let filtered_panes = match query_kind { NavigatorQueryKind::Empty => pane_rows, NavigatorQueryKind::State(filter) => pane_rows @@ -474,10 +481,8 @@ impl AppState { .collect::>(), }; - if let Some(tab_row) = tab_row { - if tab_matches || !filtered_panes.is_empty() { - rows.push(tab_row); - } + if show_tab_row && (tab_matches || !filtered_panes.is_empty()) { + rows.push(tab_row); } rows.extend(filtered_panes); } @@ -519,7 +524,7 @@ impl AppState { &self, ws_idx: usize, tab_idx: usize, - multi_tab: bool, + show_tab_row: bool, ) -> Vec { let Some(ws) = self.workspaces.get(ws_idx) else { return Vec::new(); @@ -578,7 +583,7 @@ impl AppState { tab_idx, pane_id, }, - depth: if multi_tab { 2 } else { 1 }, + depth: if show_tab_row { 2 } else { 1 }, label, meta, status: state, @@ -3670,6 +3675,52 @@ mod tests { ))); } + #[test] + fn navigator_search_matches_named_tabs_in_single_tab_workspaces() { + let mut state = app_with_workspaces(&["multi", "single"]); + state.workspaces[0].tabs[0].custom_name = Some("Foo".into()); + state.workspaces[0].test_add_tab(Some("Bar")); + state.workspaces[1].tabs[0].custom_name = Some("Baz".into()); + state.ensure_test_terminals(); + + state.open_navigator(); + state.navigator.query = "foo".into(); + assert!(state.navigator_rows().iter().any(|row| { + row.matched + && matches!( + row.target, + crate::app::state::NavigatorTarget::Tab { + ws_idx: 0, + tab_idx: 0 + } + ) + })); + + state.navigator.query = "baz".into(); + state.select_first_navigator_match_from(&crate::terminal::TerminalRuntimeRegistry::new()); + let rows = state.navigator_rows(); + assert!(rows + .get(state.navigator.selected) + .is_some_and(|row| matches!( + row.target, + crate::app::state::NavigatorTarget::Tab { + ws_idx: 1, + tab_idx: 0 + } + ))); + assert!(!rows.iter().any(|row| matches!( + row.target, + crate::app::state::NavigatorTarget::Workspace { ws_idx: 0 } + | crate::app::state::NavigatorTarget::Tab { ws_idx: 0, .. } + | crate::app::state::NavigatorTarget::Pane { ws_idx: 0, .. } + ))); + + assert!(state.accept_navigator_selection()); + assert_eq!(state.active, Some(1)); + assert_eq!(state.workspaces[1].active_tab_index(), 0); + assert_eq!(state.mode, Mode::Terminal); + } + #[tokio::test] async fn navigator_rows_match_live_root_runtime_cwd_workspace_label() { let unique = format!( From 09cdd88d0aca35617eb05468c2421b0467656e4f Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Tue, 4 Aug 2026 16:35:15 -0500 Subject: [PATCH 4/7] fix(layout): return focus to the pane a split was opened from (#2266) Closing a focused pane handed focus to the next pane in tree order. For a pane opened beside another one -- a plugin split, a file viewer, any transient tool pane -- that is rarely where the user was: it lands on some unrelated neighbour rather than the pane that opened it. Track the pane focus came from in TileLayout and prefer it when the focused pane closes, falling back to tree order when there is no history, when it points at the pane being closed, or when it points at a pane that has since gone away. The history lives in the layout, so it can only ever name a pane in the same tab. A one-slot history is only sound if internal focus excursions never write it, so the tree edits that used to bounce focus around now go through target-taking primitives instead. close_pane removes a background pane directly, so detach_pane and take_pane_for_move stop focus-close-refocusing. split_pane splits a target without moving focus: the runtime split path only focuses the new pane once the spawn succeeds, which makes a failed split a pure rollback, and the targeted and unfocused workspace split paths stop fabricating history. insert_pane_near now takes the focus intent, so an unfocused pane move leaves the target tab's history alone. The layout-level focused-split helpers become test-only; production splits all flow through the target-taking path. Co-authored-by: Can Celik --- src/app/api/panes.rs | 10 +- src/layout.rs | 236 ++++++++++++++++++++++++++++++++++++++++--- src/workspace.rs | 100 +++++++----------- src/workspace/tab.rs | 118 ++++++++++------------ 4 files changed, 316 insertions(+), 148 deletions(-) diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index a5f1cbcb..97fa0633 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -871,10 +871,6 @@ impl App { self.recover_failed_pane_move(recovery_context, moved); return encode_error(id, "pane_move_failed", "target tab disappeared"); }; - let previous_target_focus = self.state.workspaces[target_ws_idx].tabs - [target_tab_idx] - .layout - .focused(); let direction = split_direction_to_layout(split); let moved_pane_id = match self.state.workspaces[target_ws_idx] .insert_moved_pane_into_tab( @@ -883,6 +879,7 @@ impl App { moved, direction, ratio, + focus, ) { Ok(pane_id) => pane_id, Err(moved) => { @@ -894,11 +891,6 @@ impl App { ); } }; - if !focus { - self.state.workspaces[target_ws_idx].tabs[target_tab_idx] - .layout - .focus_pane(previous_target_focus); - } (target_ws_idx, target_tab_idx, moved_pane_id) } ResolvedPaneMoveDestination::NewTab { diff --git a/src/layout.rs b/src/layout.rs index 8a16da9c..c46a0753 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -84,6 +84,11 @@ pub enum Node { pub struct TileLayout { root: Node, focus: PaneId, + /// Pane focused before `focus`, used by `close_focused`. Only a real focus + /// move writes it; tree edits go through the target-taking primitives + /// (`split_pane`, `close_pane`, unfocused `insert_pane_near`) so internal + /// focus excursions never corrupt it. + prev_focus: Option, } impl TileLayout { @@ -95,11 +100,20 @@ impl TileLayout { Self { root: Node::Pane(root_id), focus: root_id, + prev_focus: None, }, root_id, ) } + /// Move focus, recording the pane being left. No-op when focus is unchanged. + fn set_focus(&mut self, id: PaneId) { + if id != self.focus { + self.prev_focus = Some(self.focus); + self.focus = id; + } + } + pub fn focused(&self) -> PaneId { self.focus } @@ -122,29 +136,52 @@ impl TileLayout { result } - /// Split the focused pane. Returns the new pane's id. + /// Split the focused pane. Returns the new pane's id. Production splits + /// flow through `Tab` so a failed runtime spawn can roll back; this remains + /// as the user-split shape for tests. + #[cfg(test)] pub fn split_focused(&mut self, direction: Direction) -> PaneId { self.split_focused_with_ratio(direction, 0.5) } /// Split the focused pane with a custom first-child ratio. + #[cfg(test)] pub fn split_focused_with_ratio(&mut self, direction: Direction, ratio: f32) -> PaneId { - let new_id = PaneId::alloc(); - let placeholder = PaneId::from_raw(0); - let old = std::mem::replace(&mut self.root, Node::Pane(placeholder)); - self.root = split_at(old, self.focus, direction, new_id, valid_split_ratio(ratio)); - self.focus = new_id; + let new_id = self + .split_pane(self.focus, direction, ratio) + .expect("focused pane is in the layout"); + self.set_focus(new_id); new_id } + /// Split `target` without moving focus. Returns the new pane's id, or None + /// when `target` is not in the layout. + pub fn split_pane( + &mut self, + target: PaneId, + direction: Direction, + ratio: f32, + ) -> Option { + if !self.pane_ids().contains(&target) { + return None; + } + let new_id = PaneId::alloc(); + let placeholder = PaneId::from_raw(0); + let old = std::mem::replace(&mut self.root, Node::Pane(placeholder)); + self.root = split_at(old, target, direction, new_id, valid_split_ratio(ratio)); + Some(new_id) + } + /// Insert an existing pane id next to a target pane without allocating a new - /// pane or spawning a terminal runtime. + /// pane or spawning a terminal runtime. When `focus` is false, focus and its + /// history are left untouched. pub fn insert_pane_near( &mut self, target: PaneId, moved: PaneId, direction: Direction, ratio: f32, + focus: bool, ) -> bool { if target == moved { return false; @@ -157,11 +194,14 @@ impl TileLayout { let placeholder = PaneId::from_raw(0); let old = std::mem::replace(&mut self.root, Node::Pane(placeholder)); self.root = split_at(old, target, direction, moved, valid_split_ratio(ratio)); - self.focus = moved; + if focus { + self.set_focus(moved); + } true } - /// Close the focused pane. Returns false if it's the last pane. + /// Close the focused pane, returning focus to the pane it came from when + /// that pane is still open. Returns false if it's the last pane. pub fn close_focused(&mut self) -> bool { if self.pane_count() <= 1 { return false; @@ -169,25 +209,51 @@ impl TileLayout { let target = self.focus; let ids = self.pane_ids(); let pos = ids.iter().position(|id| *id == target).unwrap(); - let new_focus = if pos + 1 < ids.len() { + let ordered = if pos + 1 < ids.len() { ids[pos + 1] } else { ids[pos - 1] }; + let new_focus = match self.prev_focus { + Some(prev) if prev != target && ids.contains(&prev) => prev, + _ => ordered, + }; let placeholder = PaneId::from_raw(0); let old = std::mem::replace(&mut self.root, Node::Pane(placeholder)); if let Some(new_root) = remove_pane(old, target) { self.root = new_root; self.focus = new_focus; + self.prev_focus = None; true } else { false } } + /// Close any pane. Focus and its history are left alone unless the closed + /// pane is the focused one. + pub fn close_pane(&mut self, id: PaneId) -> bool { + if self.focus == id { + return self.close_focused(); + } + if self.pane_count() <= 1 || !self.pane_ids().contains(&id) { + return false; + } + let placeholder = PaneId::from_raw(0); + let old = std::mem::replace(&mut self.root, Node::Pane(placeholder)); + let Some(new_root) = remove_pane(old, id) else { + return false; + }; + self.root = new_root; + if self.prev_focus == Some(id) { + self.prev_focus = None; + } + true + } + pub fn focus_pane(&mut self, id: PaneId) { if self.pane_ids().contains(&id) { - self.focus = id; + self.set_focus(id); } } @@ -270,7 +336,11 @@ impl TileLayout { /// Reconstruct a layout from a saved tree. /// Reconstruct a layout from a saved tree. pub fn from_saved(root: Node, focus: PaneId) -> Self { - Self { root, focus } + Self { + root, + focus, + prev_focus: None, + } } } @@ -746,7 +816,7 @@ mod tests { let (mut layout, root) = TileLayout::new(); let moved = pane(99); - assert!(layout.insert_pane_near(root, moved, Direction::Horizontal, 0.25)); + assert!(layout.insert_pane_near(root, moved, Direction::Horizontal, 0.25, true)); assert_eq!(layout.pane_count(), 2); assert_eq!(layout.pane_ids(), vec![root, moved]); @@ -954,4 +1024,144 @@ mod tests { Some(pane(3)) ); } + + #[test] + fn close_focused_returns_to_the_pane_focus_came_from() { + let mut layout = sample_layout(); + layout.focus_pane(pane(4)); + + assert!(layout.close_focused()); + + assert_eq!(layout.focused(), pane(2)); + } + + #[test] + fn close_focused_returns_to_the_pane_that_opened_a_split() { + // Allocated ids only: sample_layout() uses from_raw and shares the id + // space with the allocator. + let (mut layout, first) = TileLayout::new(); + let second = layout.split_focused(Direction::Horizontal); + let third = layout.split_focused(Direction::Vertical); + assert_eq!(layout.pane_ids().len(), 3); + + layout.focus_pane(first); + let opened = layout.split_focused(Direction::Horizontal); + assert_eq!(layout.focused(), opened); + + assert!(layout.close_focused()); + + assert_eq!(layout.focused(), first); + assert!(layout.pane_ids().contains(&second)); + assert!(layout.pane_ids().contains(&third)); + } + + #[test] + fn closing_a_background_pane_keeps_the_focused_pane_history() { + let mut layout = sample_layout(); + layout.focus_pane(pane(4)); + + assert!(layout.close_pane(pane(1))); + assert_eq!(layout.focused(), pane(4)); + + assert!(layout.close_focused()); + assert_eq!(layout.focused(), pane(2)); + } + + #[test] + fn closing_the_remembered_pane_drops_the_focus_history() { + let mut layout = sample_layout(); + layout.focus_pane(pane(4)); + + assert!(layout.close_pane(pane(2))); + + assert!(layout.close_focused()); + assert_eq!(layout.focused(), pane(3)); + } + + #[test] + fn close_focused_uses_tree_order_without_focus_history() { + let mut layout = sample_layout(); + + assert!(layout.close_focused()); + + assert_eq!(layout.focused(), pane(3)); + } + + #[test] + fn close_focused_does_not_reuse_history_after_it_is_consumed() { + let mut layout = sample_layout(); + layout.focus_pane(pane(4)); + + assert!(layout.close_focused()); + assert_eq!(layout.focused(), pane(2)); + + assert!(layout.close_focused()); + assert_eq!(layout.focused(), pane(3)); + } + + #[test] + fn resize_does_not_disturb_the_close_focus_target() { + let mut layout = sample_layout(); + layout.focus_pane(pane(4)); + layout.resize_pane(pane(1), NavDirection::Right, 0.05, Rect::new(0, 0, 100, 40)); + + assert!(layout.close_focused()); + + assert_eq!(layout.focused(), pane(2)); + } + + #[test] + fn split_pane_leaves_focus_and_history_untouched() { + let mut layout = sample_layout(); + layout.focus_pane(pane(4)); + + let new_id = layout + .split_pane(pane(1), Direction::Horizontal, 0.5) + .expect("target exists"); + + assert!(layout.pane_ids().contains(&new_id)); + assert_eq!(layout.focused(), pane(4)); + assert!(layout.close_focused()); + assert_eq!(layout.focused(), pane(2)); + } + + #[test] + fn split_pane_missing_target_changes_nothing() { + let mut layout = sample_layout(); + let ids = layout.pane_ids(); + + assert_eq!( + layout.split_pane(pane(99), Direction::Horizontal, 0.5), + None + ); + + assert_eq!(layout.pane_ids(), ids); + } + + #[test] + fn insert_pane_near_unfocused_keeps_focus_and_history() { + let mut layout = sample_layout(); + layout.focus_pane(pane(4)); + + assert!(layout.insert_pane_near(pane(1), pane(9), Direction::Horizontal, 0.5, false)); + + assert_eq!(layout.focused(), pane(4)); + assert!(layout.close_focused()); + assert_eq!(layout.focused(), pane(2)); + } + + #[test] + fn failed_split_rollback_preserves_focus_history() { + let mut layout = sample_layout(); + layout.focus_pane(pane(4)); + + let new_id = layout + .split_pane(layout.focused(), Direction::Horizontal, 0.5) + .expect("target exists"); + assert!(layout.close_pane(new_id)); + + assert_eq!(layout.focused(), pane(4)); + assert!(layout.close_focused()); + assert_eq!(layout.focused(), pane(2)); + } } diff --git a/src/workspace.rs b/src/workspace.rs index 0d0e35bd..bfda45f6 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -891,70 +891,40 @@ impl Workspace { let tab_number = self.tabs[tab_idx].number; let launch_env = self.launch_env_for_new_pane(tab_number, pane_number, extra_env); let tab = &mut self.tabs[tab_idx]; - let previous_focus = tab.layout.focused(); - tab.layout.focus_pane(pane_id); let new_pane = match if let Some(argv) = argv { - match ratio { - Some(ratio) => tab.split_focused_argv_command_with_ratio( - direction, - ratio, - rows, - cols, - cwd, - argv, - &launch_env, - scrollback_limit_bytes, - host_terminal_theme, - host_terminal_appearance, - ), - None => tab.split_focused_argv_command( - direction, - rows, - cols, - cwd, - argv, - &launch_env, - scrollback_limit_bytes, - host_terminal_theme, - host_terminal_appearance, - ), - } + tab.split_pane_argv( + pane_id, + focus_new_pane, + direction, + ratio, + rows, + cols, + cwd, + argv, + &launch_env, + scrollback_limit_bytes, + host_terminal_theme, + host_terminal_appearance, + ) } else { - match ratio { - Some(ratio) => tab.split_focused_with_ratio( - direction, - ratio, - rows, - cols, - cwd, - scrollback_limit_bytes, - host_terminal_theme, - host_terminal_appearance, - shell_config, - &launch_env, - ), - None => tab.split_focused( - direction, - rows, - cols, - cwd, - scrollback_limit_bytes, - host_terminal_theme, - host_terminal_appearance, - shell_config, - &launch_env, - ), - } + tab.split_pane_shell( + pane_id, + focus_new_pane, + direction, + ratio, + rows, + cols, + cwd, + scrollback_limit_bytes, + host_terminal_theme, + host_terminal_appearance, + shell_config, + &launch_env, + ) } { Ok(new_pane) => new_pane, - Err(err) => { - tab.layout.focus_pane(previous_focus); - return Some(Err(err)); - } + Err(err) => return Some(Err(err)), }; - if !focus_new_pane { - tab.layout.focus_pane(previous_focus); - } self.register_new_pane_with_number(new_pane.pane_id, pane_number); Some(Ok((tab_idx, new_pane))) } @@ -1034,12 +1004,13 @@ impl Workspace { moved: MovedPane, direction: Direction, ratio: f32, + focus: bool, ) -> Result { let pane_id = moved.pane_id; let Some(tab) = self.tabs.get_mut(tab_idx) else { return Err(moved); }; - tab.insert_existing_pane(target_pane_id, moved, direction, ratio)?; + tab.insert_existing_pane(target_pane_id, moved, direction, ratio, focus)?; if !self.public_pane_numbers.contains_key(&pane_id) { self.register_new_pane_with_number(pane_id, self.next_public_pane_number); } @@ -1665,7 +1636,14 @@ mod tests { let missing_target = PaneId::alloc(); let recovered = target - .insert_moved_pane_into_tab(0, missing_target, taken.moved, Direction::Horizontal, 0.5) + .insert_moved_pane_into_tab( + 0, + missing_target, + taken.moved, + Direction::Horizontal, + 0.5, + true, + ) .expect_err("invalid target should return the moved pane"); assert_eq!(recovered.pane_id, source_pane); diff --git a/src/workspace/tab.rs b/src/workspace/tab.rs index 5cc35b68..6f0fa75e 100644 --- a/src/workspace/tab.rs +++ b/src/workspace/tab.rs @@ -205,6 +205,7 @@ impl Tab { self.custom_name = Some(name); } + #[cfg(test)] pub fn split_focused( &mut self, direction: Direction, @@ -217,7 +218,9 @@ impl Tab { shell_config: crate::pane::PaneShellConfig<'_>, launch_env: &PaneLaunchEnv, ) -> std::io::Result { - self.split_focused_with_runtime( + self.split_pane_with_runtime( + self.layout.focused(), + true, direction, None, rows, @@ -232,34 +235,6 @@ impl Tab { ) } - pub fn split_focused_with_ratio( - &mut self, - direction: Direction, - ratio: f32, - rows: u16, - cols: u16, - cwd: Option, - scrollback_limit_bytes: usize, - host_terminal_theme: crate::terminal_theme::TerminalTheme, - host_terminal_appearance: Option, - shell_config: crate::pane::PaneShellConfig<'_>, - launch_env: &PaneLaunchEnv, - ) -> std::io::Result { - self.split_focused_with_runtime( - direction, - Some(ratio), - rows, - cols, - cwd, - scrollback_limit_bytes, - host_terminal_theme, - host_terminal_appearance, - shell_config, - launch_env, - None, - ) - } - pub fn split_focused_command( &mut self, direction: Direction, @@ -272,7 +247,9 @@ impl Tab { host_terminal_theme: crate::terminal_theme::TerminalTheme, host_terminal_appearance: Option, ) -> std::io::Result { - self.split_focused_with_runtime( + self.split_pane_with_runtime( + self.layout.focused(), + true, direction, None, rows, @@ -290,37 +267,51 @@ impl Tab { ) } - pub fn split_focused_argv_command( + /// Split `target` with a shell pane. Focus moves to the new pane only when + /// `focus_new_pane` is set; a spawn failure rolls the layout back without + /// touching focus or its history. + #[allow(clippy::too_many_arguments)] + pub(crate) fn split_pane_shell( &mut self, + target: PaneId, + focus_new_pane: bool, direction: Direction, + ratio: Option, rows: u16, cols: u16, cwd: Option, - argv: &[String], - launch_env: &PaneLaunchEnv, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, host_terminal_appearance: Option, + shell_config: crate::pane::PaneShellConfig<'_>, + launch_env: &PaneLaunchEnv, ) -> std::io::Result { - self.split_focused_with_runtime( + self.split_pane_with_runtime( + target, + focus_new_pane, direction, - None, + ratio, rows, cols, cwd, scrollback_limit_bytes, host_terminal_theme, host_terminal_appearance, - crate::pane::PaneShellConfig::new("", crate::config::ShellModeConfig::NonLogin), + shell_config, launch_env, - Some(SplitCommand::Argv { argv, launch_env }), + None, ) } - pub fn split_focused_argv_command_with_ratio( + /// Split `target` with an argv-command pane. Same focus contract as + /// `split_pane_shell`. + #[allow(clippy::too_many_arguments)] + pub(crate) fn split_pane_argv( &mut self, + target: PaneId, + focus_new_pane: bool, direction: Direction, - ratio: f32, + ratio: Option, rows: u16, cols: u16, cwd: Option, @@ -330,9 +321,11 @@ impl Tab { host_terminal_theme: crate::terminal_theme::TerminalTheme, host_terminal_appearance: Option, ) -> std::io::Result { - self.split_focused_with_runtime( + self.split_pane_with_runtime( + target, + focus_new_pane, direction, - Some(ratio), + ratio, rows, cols, cwd, @@ -347,8 +340,10 @@ impl Tab { // Split construction threads geometry, host context, launch policy, and command state. #[allow(clippy::too_many_arguments)] - fn split_focused_with_runtime( + fn split_pane_with_runtime( &mut self, + target: PaneId, + focus_new_pane: bool, direction: Direction, ratio: Option, rows: u16, @@ -361,10 +356,14 @@ impl Tab { launch_env: &PaneLaunchEnv, command: Option>, ) -> std::io::Result { - let previous_focus = self.layout.focused(); - let new_id = match ratio { - Some(ratio) => self.layout.split_focused_with_ratio(direction, ratio), - None => self.layout.split_focused(direction), + let Some(new_id) = self + .layout + .split_pane(target, direction, ratio.unwrap_or(0.5)) + else { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "split target pane is not in the layout", + )); }; let actual_cwd = cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into())); @@ -425,8 +424,7 @@ impl Tab { let runtime = match runtime { Ok(runtime) => runtime, Err(err) => { - self.layout.close_focused(); - self.layout.focus_pane(previous_focus); + self.layout.close_pane(new_id); return Err(err); } }; @@ -437,6 +435,9 @@ impl Tab { } None => TerminalState::new(terminal_id.clone(), actual_cwd), }; + if focus_new_pane { + self.layout.focus_pane(new_id); + } self.panes.insert(new_id, PaneState::new(terminal_id)); self.zoomed = false; Ok(NewPane { @@ -493,14 +494,7 @@ impl Tab { if self.layout.pane_count() > 1 { let next_root = self.promoted_root_if_needed(pane_id); - if self.layout.focused() == pane_id { - self.layout.close_focused(); - } else { - let prev_focus = self.layout.focused(); - self.layout.focus_pane(pane_id); - self.layout.close_focused(); - self.layout.focus_pane(prev_focus); - } + self.layout.close_pane(pane_id); if let Some(next_root) = next_root { self.root_pane = next_root; } @@ -520,10 +514,11 @@ impl Tab { moved: MovedPane, direction: Direction, ratio: f32, + focus: bool, ) -> Result { if !self .layout - .insert_pane_near(target_pane_id, moved.pane_id, direction, ratio) + .insert_pane_near(target_pane_id, moved.pane_id, direction, ratio, focus) { return Err(moved); } @@ -540,14 +535,7 @@ impl Tab { let next_root = self.promoted_root_if_needed(pane_id); - if self.layout.focused() == pane_id { - self.layout.close_focused(); - } else { - let prev_focus = self.layout.focused(); - self.layout.focus_pane(pane_id); - self.layout.close_focused(); - self.layout.focus_pane(prev_focus); - } + self.layout.close_pane(pane_id); let pane = self.panes.remove(&pane_id)?; let terminal_id = pane.attached_terminal_id; From be1891ec5265e901b897846a6113c46cf330a7dc Mon Sep 17 00:00:00 2001 From: Kazunari Kamata <14287197+kazunari-kamata@users.noreply.github.com> Date: Wed, 5 Aug 2026 06:48:15 +0900 Subject: [PATCH 5/7] fix(terminal): render halfwidth katakana voiced marks (#2257) Co-authored-by: oyoguhito Co-authored-by: Can Celik --- src/pane/terminal.rs | 98 +++++++++++++++++++++++++++++++++++-- src/protocol/render_ansi.rs | 85 ++++++++++++++++++++++++++++++++ src/server/headless.rs | 72 ++++++++++++++++++++++++++- 3 files changed, 249 insertions(+), 6 deletions(-) diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index d4f167a9..4bd50669 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -2654,10 +2654,26 @@ pub(super) fn ghostty_normalize_buffer_symbol( if wide == crate::ghostty::CellWide::Narrow && actual_width == 2 { return symbol.to_string(); } + if wide == crate::ghostty::CellWide::Wide && is_halfwidth_katakana_voiced_grapheme(symbol) { + return symbol.to_string(); + } ghostty_blank_symbol_for_width(wide).to_string() } +fn is_halfwidth_katakana_voiced_grapheme(symbol: &str) -> bool { + let mut chars = symbol.chars(); + let Some(base) = chars.next() else { + return false; + }; + let Some(mark) = chars.next() else { + return false; + }; + chars.next().is_none() + && ('\u{ff66}'..='\u{ff9d}').contains(&base) + && matches!(mark, '\u{ff9e}' | '\u{ff9f}') +} + fn ghostty_buffer_symbol_into<'a>( cells: &crate::ghostty::RowCellIter<'_>, wide: crate::ghostty::CellWide, @@ -2689,6 +2705,8 @@ fn ghostty_buffer_symbol_into<'a>( let actual_width = symbol_scratch.width(); if actual_width != expected_width && !(wide == crate::ghostty::CellWide::Narrow && actual_width == 2) + && !(wide == crate::ghostty::CellWide::Wide + && is_halfwidth_katakana_voiced_grapheme(symbol_scratch)) { symbol_scratch.clear(); symbol_scratch.push_str(ghostty_blank_symbol_for_width(wide)); @@ -2721,11 +2739,7 @@ fn blank_cell_data(default_fg: Option, default_bg: Option) -> Cell fn cell_data_from_style(symbol: String, style: Style) -> CellData { CellData { - symbol: if symbol.is_empty() { - " ".to_string() - } else { - symbol - }, + symbol, fg: crate::protocol::color_to_u32(style.fg.unwrap_or(Color::Reset)), bg: crate::protocol::color_to_u32(style.bg.unwrap_or(Color::Reset)), modifier: crate::protocol::modifier_to_u16(style.add_modifier), @@ -4441,6 +4455,14 @@ mod tests { ghostty_normalize_buffer_symbol("xx", crate::ghostty::CellWide::SpacerHead), " " ); + assert_eq!( + ghostty_normalize_buffer_symbol("カ\u{ff9e}", crate::ghostty::CellWide::Wide), + "カ\u{ff9e}" + ); + assert_eq!( + ghostty_normalize_buffer_symbol("ハ\u{ff9f}", crate::ghostty::CellWide::Wide), + "ハ\u{ff9f}" + ); } fn render_cells_to_symbols( @@ -4509,6 +4531,72 @@ mod tests { ); } + #[test] + fn halfwidth_katakana_voiced_marks_render() { + let mut terminal = crate::ghostty::Terminal::new(40, 1, 0).unwrap(); + terminal.write("アイウエオ ガギグゲゴ パピプペポ".as_bytes()); + + let cells = render_cells_to_symbols(&mut terminal); + let rendered: String = cells.iter().map(|(_, symbol)| symbol.as_str()).collect(); + + assert!( + rendered.contains("アイウエオ ガギグゲゴ パピプペポ"), + "expected halfwidth katakana with voiced marks to survive, got {cells:?}" + ); + } + + #[test] + fn render_keeps_halfwidth_katakana_voiced_tail_empty() { + let (tx, _rx) = mpsc::channel(4); + let mut terminal = crate::ghostty::Terminal::new(20, 1, 0).unwrap(); + terminal.write("ガZ".as_bytes()); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + + let backend = ratatui::backend::TestBackend::new(20, 1); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + terminal + .draw(|frame| pane.render(frame, Rect::new(0, 0, 20, 1), false)) + .unwrap(); + let buffer = terminal.backend().buffer(); + + assert_eq!(buffer[(0, 0)].symbol(), "カ\u{ff9e}"); + assert_eq!( + buffer[(1, 0)].symbol(), + "", + "wide spacer tail must stay empty so the host terminal does not overwrite the voiced kana" + ); + assert_eq!(buffer[(2, 0)].symbol(), "Z"); + } + + #[test] + fn dirty_patch_keeps_halfwidth_katakana_voiced_tail_empty() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(20, 1, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + let backend = ratatui::backend::TestBackend::new(20, 1); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + terminal + .draw(|frame| pane.render(frame, Rect::new(0, 0, 20, 1), false)) + .unwrap(); + { + let mut core = pane.core.lock().unwrap(); + core.terminal.write("ガZ".as_bytes()); + } + + let patch = match pane.collect_dirty_patch(20, 1) { + TerminalDirtyPatchOutcome::Patch(patch) => patch, + other => panic!("expected dirty patch, got {other:?}"), + }; + let row = &patch.rows[0].1; + + assert_eq!(row[0].symbol, "カ\u{ff9e}"); + assert_eq!( + row[1].symbol, "", + "wide spacer tail must stay empty in retained terminal patches" + ); + assert_eq!(row[2].symbol, "Z"); + } + #[test] fn pane_scrollback_controls_round_trip_and_clamp_without_ui_interference() { let (tx, _rx) = mpsc::channel(4); diff --git a/src/protocol/render_ansi.rs b/src/protocol/render_ansi.rs index 6cee4fde..1e6b9aa4 100644 --- a/src/protocol/render_ansi.rs +++ b/src/protocol/render_ansi.rs @@ -522,9 +522,25 @@ fn repeat_ime_anchor_after_sync() -> bool { /// Writes all cells in the frame (full redraw). fn cell_width(cell: &CellData) -> usize { + if is_halfwidth_katakana_voiced_grapheme(&cell.symbol) { + return 2; + } cell.symbol.width() } +fn is_halfwidth_katakana_voiced_grapheme(symbol: &str) -> bool { + let mut chars = symbol.chars(); + let Some(base) = chars.next() else { + return false; + }; + let Some(mark) = chars.next() else { + return false; + }; + chars.next().is_none() + && ('\u{ff66}'..='\u{ff9d}').contains(&base) + && matches!(mark, '\u{ff9e}' | '\u{ff9f}') +} + #[derive(Clone, Copy)] struct HostCursorState { position: (u16, u16), @@ -819,6 +835,7 @@ mod tests { use crate::protocol::{CellData, CursorState}; const WIDE_GRAPHEME: &str = "💡"; + const HALFWIDTH_VOICED_KANA: &str = "カ\u{ff9e}"; fn make_cell(symbol: &str, fg: u32, bg: u32, modifier: u16) -> CellData { CellData { @@ -831,6 +848,12 @@ mod tests { } } + fn make_skip_cell(symbol: &str, fg: u32, bg: u32, modifier: u16) -> CellData { + let mut cell = make_cell(symbol, fg, bg, modifier); + cell.skip = true; + cell + } + fn make_frame(width: u16, height: u16, cells: Vec) -> FrameData { FrameData { cells, @@ -1828,6 +1851,30 @@ mod tests { assert!(output_str.contains("\x1b[1;3H")); } + #[test] + fn full_redraw_skips_trailing_cells_covered_by_halfwidth_voiced_kana() { + let frame = FrameData { + cells: vec![ + make_cell(HALFWIDTH_VOICED_KANA, 0, 0, 0), + make_skip_cell(" ", 0, 0, 0), + make_cell("Z", 0, 0, 0), + ], + width: 3, + height: 1, + cursor: None, + hyperlinks: Vec::new(), + graphics: Vec::new(), + }; + + let mut output = Vec::new(); + blit_frame_to(&mut output, &frame, None); + let output_str = String::from_utf8(output).unwrap(); + + assert!(output_str.contains("\x1b[1;1H")); + assert!(!output_str.contains("\x1b[1;2H")); + assert!(output_str.contains("\x1b[1;3H")); + } + #[test] fn diff_redraw_reveals_cells_hidden_by_previous_wide_graphemes() { let prev = FrameData { @@ -1900,4 +1947,42 @@ mod tests { assert!(output_str.contains("\x1b[1;1H")); assert!(!output_str.contains("\x1b[1;2H")); } + + #[test] + fn diff_redraw_reveals_cells_hidden_by_previous_halfwidth_voiced_kana() { + let prev = FrameData { + cells: vec![ + make_cell(HALFWIDTH_VOICED_KANA, 0, 0, 0), + make_skip_cell(" ", 0, 0, 0), + make_cell("Z", 0, 0, 0), + ], + width: 3, + height: 1, + cursor: None, + hyperlinks: Vec::new(), + graphics: Vec::new(), + }; + let curr = FrameData { + cells: vec![ + make_cell("A", 0, 0, 0), + make_cell(" ", 0, 0, 0), + make_cell("Z", 0, 0, 0), + ], + width: 3, + height: 1, + cursor: None, + hyperlinks: Vec::new(), + graphics: Vec::new(), + }; + + let mut output = Vec::new(); + blit_frame_to(&mut output, &curr, Some(&prev)); + let output_str = String::from_utf8(output).unwrap(); + + assert!(output_str.contains("\x1b[1;1H")); + assert!( + output_str.contains("\x1b[1;2H"), + "cells hidden by a previous halfwidth voiced kana must be redrawn when visible" + ); + } } diff --git a/src/server/headless.rs b/src/server/headless.rs index b4f433f7..859e02af 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -4910,7 +4910,8 @@ mod tests { use super::*; use crate::app::AppState; - use crate::protocol::CursorState; + use crate::protocol::{CellData, CursorState}; + use unicode_width::UnicodeWidthStr; #[path = "pane_graphics.rs"] mod pane_graphics_tests; @@ -5350,6 +5351,16 @@ mod tests { for (idx, (actual_cell, expected_cell)) in actual.cells.iter().zip(expected.cells.iter()).enumerate() { + if cells_equivalent_for_frame_compare( + &actual.cells, + &expected.cells, + usize::from(actual.width), + idx, + actual_cell, + expected_cell, + ) { + continue; + } assert_eq!( actual_cell, expected_cell, @@ -5360,6 +5371,65 @@ mod tests { } } + fn cells_equivalent_for_frame_compare( + actual_cells: &[CellData], + expected_cells: &[CellData], + width: usize, + idx: usize, + actual: &CellData, + expected: &CellData, + ) -> bool { + if actual == expected { + return true; + } + if !cell_style_without_symbol_eq(actual, expected) { + return false; + } + if !matches!( + (actual.symbol.as_str(), expected.symbol.as_str()), + ("", " ") | (" ", "") + ) { + return false; + } + covered_by_previous_wide_cell(actual_cells, width, idx) + || covered_by_previous_wide_cell(expected_cells, width, idx) + } + + fn cell_style_without_symbol_eq(a: &CellData, b: &CellData) -> bool { + a.fg == b.fg + && a.bg == b.bg + && a.modifier == b.modifier + && a.skip == b.skip + && a.hyperlink == b.hyperlink + } + + fn covered_by_previous_wide_cell(cells: &[CellData], width: usize, idx: usize) -> bool { + if idx == 0 || idx.is_multiple_of(width) { + return false; + } + frame_cell_display_width(&cells[idx - 1]) > 1 + } + + fn frame_cell_display_width(cell: &CellData) -> usize { + if is_halfwidth_katakana_voiced_grapheme(&cell.symbol) { + return 2; + } + cell.symbol.width() + } + + fn is_halfwidth_katakana_voiced_grapheme(symbol: &str) -> bool { + let mut chars = symbol.chars(); + let Some(base) = chars.next() else { + return false; + }; + let Some(mark) = chars.next() else { + return false; + }; + chars.next().is_none() + && ('\u{ff66}'..='\u{ff9d}').contains(&base) + && matches!(mark, '\u{ff9e}' | '\u{ff9f}') + } + #[test] fn foreground_client_applies_client_keybindings() { let mut server = test_headless_server(); From d4f2540cfa9229a5ce24191ddc2c1c9a2138250d Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Wed, 5 Aug 2026 00:57:30 +0300 Subject: [PATCH 6/7] fix(ci): make issue gate structural --- .github/ISSUE_TEMPLATE/bug.yml | 2 +- .github/workflows/issue-gate.yml | 88 ++++++++++++++++++-------------- CONTRIBUTING.md | 2 +- 3 files changed, 53 insertions(+), 39 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index d8b6e231..ebf054b5 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -10,7 +10,7 @@ body: Contributors who are not maintainers may open focused bug-fix PRs when the title uses `fix: ...` or `fix(scope): ...` and the patch changes no more than 20 files and 1,000 total added or deleted lines. Features and larger changes require maintainer approval first. - Keep this short. If it does not fit on one screen, it is too long. Write in your own voice. + Keep this short. If it does not fit on one screen, it is too long. Reports over 8,000 characters are closed automatically. Write in your own voice. Use only the sections in this template. Do not add root cause, proposed fix, analysis, implementation plan, or similar sections unless a maintainer asks. diff --git a/.github/workflows/issue-gate.yml b/.github/workflows/issue-gate.yml index dd93e02b..b85c437f 100644 --- a/.github/workflows/issue-gate.yml +++ b/.github/workflows/issue-gate.yml @@ -22,6 +22,7 @@ jobs: const author = issue.user.login; const sender = context.payload.sender?.login ?? author; const bugConfirmationPattern = /^\s*-\s*\[[xX]\]\s*I confirm this is a reproducible bug, not a feature request, idea, question, contribution proposal, or direction check\.\s*$/m; + const reproductionConfirmationPattern = /^\s*-\s*\[[xX]\]\s*I reproduced this bug on the version and environment reported below using the exact steps provided\.\s*$/m; const requiredSections = [ '### Is this a reproducible bug?', '### Current behavior', @@ -30,24 +31,8 @@ jobs: '### Impact', '### Environment', ]; - const requiredEnvironmentFields = [ - { - label: 'herdr version', - pattern: /^\s*(?:-\s*)?Herdr version:[^\S\r\n]*\S.*$/im, - }, - { - label: 'update channel', - pattern: /^\s*(?:-\s*)?(?:Update channel(?: \([^)]+\))?|Channel):[^\S\r\n]*\S.*$/im, - }, - { - label: 'operating system', - pattern: /^\s*(?:-\s*)?(?:Operating system|OS):[^\S\r\n]*\S.*$/im, - }, - { - label: 'terminal', - pattern: /^\s*(?:-\s*)?Terminal:[^\S\r\n]*\S.*$/im, - }, - ]; + const maxBodyLength = 8000; + const maxExtraHeadings = 1; function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -79,6 +64,17 @@ jobs: return typeof section === 'string' && section.trim().length > 0; } + function hasEnvironmentContent(section) { + if (!hasContent(section)) { + return false; + } + + return section.split(/\r?\n/).some((line) => { + const value = line.trim(); + return value.length > 0 && !/^(?:-\s*)?[^:]+:\s*$/.test(value); + }); + } + function bodyWithoutFencedCodeBlocks(value) { let inFence = false; return value @@ -130,12 +126,37 @@ jobs: return; } - const hasBugConfirmation = bugConfirmationPattern.test(body); - const hasBugTemplate = requiredSections.every((section) => body.includes(section)); + const hasBugConfirmation = bugConfirmationPattern.test(body) && reproductionConfirmationPattern.test(body); const allowedHeadings = new Set(requiredSections); const headingBody = bodyWithoutFencedCodeBlocks(body); const headings = [...headingBody.matchAll(/^\s{0,3}#{1,6}\s+(.+?)\s*$/gm)].map((match) => match[0].trim()); + const headingCounts = new Map( + requiredSections.map((section) => [ + section, + headings.filter((heading) => heading === section).length, + ]), + ); + const hasEveryRequiredHeading = requiredSections.every( + (section) => headingCounts.get(section) > 0, + ); + const hasBugTemplate = requiredSections.every( + (section) => headingCounts.get(section) === 1, + ); + const repeatedTemplateHeadings = requiredSections.filter( + (section) => headingCounts.get(section) > 1, + ); const extraHeadings = headings.filter((heading) => !allowedHeadings.has(heading)); + const structuralViolations = []; + if (repeatedTemplateHeadings.length > 0) { + structuralViolations.push('one or more required template headings are repeated'); + } + if (extraHeadings.length > maxExtraHeadings) { + structuralViolations.push(`${extraHeadings.length} extra markdown headings were added`); + } + if (body.length > maxBodyLength) { + structuralViolations.push(`the report is ${body.length} characters; the limit is ${maxBodyLength}`); + } + const currentBehavior = extractSection(body, '### Current behavior'); const expectedBehavior = extractSection(body, '### Expected behavior'); const reproduction = extractSection(body, '### Reproduction'); @@ -147,23 +168,16 @@ jobs: reproduction, impact, ].every(hasContent); - const missingEnvironmentFields = hasContent(environment) - ? requiredEnvironmentFields - .filter((field) => !field.pattern.test(environment)) - .map((field) => field.label) - : requiredEnvironmentFields.map((field) => field.label); - const hasEnvironmentFields = missingEnvironmentFields.length === 0; - if (hasBugConfirmation && hasBugTemplate && extraHeadings.length > 0) { + const hasEnvironment = hasEnvironmentContent(environment); + + if (hasBugConfirmation && hasEveryRequiredHeading && structuralViolations.length > 0) { const message = [ `hi @${author}, thanks for opening this.`, '', - 'this issue uses extra markdown headings outside the bug report template.', + 'this report exceeds the bug report structure limits:', + ...structuralViolations.map((violation) => `- ${violation}`), '', - 'please use the exact template sections only. bug reports should describe observed behavior, exact reproduction steps, impact, and environment. extra root-cause analysis, proposed fixes, implementation plans, or generated diagnosis make reports harder to triage.', - ...(hasEnvironmentFields ? [] : [ - '', - `this report is also missing required environment details: ${missingEnvironmentFields.join(', ')}.`, - ]), + 'please keep the report within the required template, under 8,000 characters, and focused on observed behavior, exact reproduction, impact, and environment.', '', 'closing this so the issue tracker stays limited to concise, actionable bug reports.', ].join('\n'); @@ -185,16 +199,16 @@ jobs: return; } - if (hasBugConfirmation && hasBugTemplate && hasRequiredContent && hasEnvironmentFields) { + if (hasBugConfirmation && hasBugTemplate && hasRequiredContent && hasEnvironment) { console.log(`#${issue.number} matches the bug report template`); return; } - if (hasBugConfirmation && hasBugTemplate && hasRequiredContent && !hasEnvironmentFields) { + if (hasBugConfirmation && hasBugTemplate && hasRequiredContent && !hasEnvironment) { const message = [ `hi @${author}, thanks for opening this.`, '', - `this bug report is missing required environment details: ${missingEnvironmentFields.join(', ')}. please edit the issue and fill in the missing fields.`, + 'this bug report has no filled environment details. please edit the environment section with the Herdr version, update channel, operating system, and terminal.', '', 'shell and relevant config are optional, but they help when they affect the bug.', ].join('\n'); @@ -206,7 +220,7 @@ jobs: body: message, }); - console.log(`#${issue.number} is missing required environment details; leaving issue open for correction`); + console.log(`#${issue.number} has no environment details; leaving issue open for correction`); return; } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 90fdb0a5..1dfa593b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -59,7 +59,7 @@ Bug reports should answer these questions clearly: If there is no reproduction yet, start a discussion instead. Search open and closed issues before submitting; add evidence to an existing issue instead of opening a duplicate. -Keep bug reports factual, concise, and within the exact template. If the completed report does not fit roughly on one screen, shorten it before submitting. Report only what you or your agent directly observed: what was done, what happened, what was expected, and what environment was used. Do not add root-cause analysis, proposed fixes, implementation plans, or diagnosis dumps unless a maintainer asks. If you use AI to help write the issue, use it to make the report clearer and shorter, not longer. +Keep bug reports factual, concise, and within the exact template. Reports over 8,000 characters are closed automatically; if the completed report does not fit roughly on one screen, shorten it before submitting. Report only what you or your agent directly observed: what was done, what happened, what was expected, and what environment was used. Do not add root-cause analysis, proposed fixes, implementation plans, or diagnosis dumps unless a maintainer asks. If you use AI to help write the issue, use it to make the report clearer and shorter, not longer. If your proposal changes the visual language, interaction model, workflow, persistence, architecture, or product direction, start a discussion instead. From d57cefb879e685b4876a84fa20cbfd0702ce4140 Mon Sep 17 00:00:00 2001 From: akbash Date: Wed, 5 Aug 2026 01:19:21 +0300 Subject: [PATCH 7/7] fix(input): preserve modifyOtherKeys key releases (#2303) refs #2302 Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com> Co-authored-by: Can Celik --- src/app/input/terminal.rs | 9 ++++++- src/app/mod.rs | 56 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs index 2442353e..bc9b2a59 100644 --- a/src/app/input/terminal.rs +++ b/src/app/input/terminal.rs @@ -282,7 +282,14 @@ impl App { None }; - runtime.is_some_and(|runtime| runtime.keyboard_protocol().reports_all_keys()) + runtime.is_some_and(|runtime| { + let protocol = runtime.keyboard_protocol(); + protocol.reports_all_keys() + || (protocol.reports_event_types() + && runtime + .input_state() + .is_some_and(|state| state.modify_other_keys)) + }) } fn terminal_input_runtime( diff --git a/src/app/mod.rs b/src/app/mod.rs index c27c249e..5e3c86de 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -5225,6 +5225,62 @@ last_pane = "prefix+tab" ); } + #[tokio::test] + async fn host_report_all_supplies_printable_releases_for_event_type_only_panes() { + let mut app = test_app(); + let mut workspace = Workspace::test_new("test"); + let focused = workspace.focused_pane_id().unwrap(); + let (runtime, mut rx) = TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, + 24, + 0, + b"\x1b[>4;2m\x1b[=3;1u", + 4, + ); + assert_eq!( + runtime.keyboard_protocol(), + crate::input::KeyboardProtocol::Kitty { flags: 3 } + ); + assert!(runtime + .input_state() + .is_some_and(|state| state.modify_other_keys)); + workspace.tabs[0].runtimes.insert(focused, runtime); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + assert!(app.host_keyboard_report_all_requested()); + + app.route_client_input(b"\x1b[106;1:1u\x1b[106;1:2u\x1b[106;1:3u".to_vec()); + assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"j")); + assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"j")); + assert_eq!( + rx.recv().await.unwrap(), + bytes::Bytes::from_static(b"\x1b[106;1:3u") + ); + assert!(rx.try_recv().is_err()); + + let runtime = app + .state + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, focused) + .unwrap(); + runtime.test_process_pty_bytes(b"\x1b[>4;0m"); + assert!(!runtime + .input_state() + .is_some_and(|state| state.modify_other_keys)); + assert!(!app.host_keyboard_report_all_requested()); + + #[cfg(unix)] + { + runtime.test_process_pty_bytes(b"\x1b[>4;1m"); + assert!(!runtime + .input_state() + .is_some_and(|state| state.modify_other_keys)); + assert!(!app.host_keyboard_report_all_requested()); + } + } + #[tokio::test] async fn host_report_all_follows_terminal_protocol_and_command_modes() { let mut app = test_app();