Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed

- Kitty temporary-file transfers require both an approved temporary directory
and the protocol filename marker. Shared-memory transfers validate the whole
requested range and copy through the kernel; `S` is a byte count independent
of `O`. Failed placements still enforce the image storage quota.
- PNG decoding expands packed grayscale, palette colors and transparency, and
checks decoded size before allocating pixel buffers. GPU uploads respect the
device dimension limit; deleting and recreating an image cannot reuse stale
texture contents.
- PTY exit waits for the reader's final output, with a two-second drain deadline
for descendants retaining the slave. User input and terminal replies share a
nonblocking byte budget; shutdown cancels writes waiting for PTY capacity.
- Search finds text across soft wraps and highlights every matching row. CSI
requests exceeding the parameter limit are ignored without changing accepted
parameter values. Panes sharing a redraw deadline emit one redraw notification.

### Changed

- Search runs on immutable snapshots in a worker with 35 ms debounce and
cancellation of superseded queries, moving history scans outside the UI
thread and terminal lock.
- Image storage uses ID/number/age indexes and caps image/frame metadata counts.
Static image placements reuse GPU uniform buffers and bind groups.

## [0.2.9] - 2026-09-05

### Fixed
Expand Down
19 changes: 14 additions & 5 deletions crates/noa-app/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ pub struct App {
next_path_probe_generation: u64,
/// The open search prompt (Cmd+F), if any — see [`SearchPromptSession`].
search_prompt: Option<SearchPromptSession>,
search_worker: Option<crate::search_worker::SearchWorker>,
/// Keyboard copy mode, bound to exactly one focused window/pane.
copy_mode: Option<CopyModeSession>,
/// Physical presses consumed by copy mode whose matching Kitty release
Expand Down Expand Up @@ -619,16 +620,23 @@ impl StartupTasks {
}))
.map_err(|e| format!("no compatible GPU adapter found ({e})"))?;
crate::startup_trace::mark("gpu-adapter-ready");
let (device, queue) =
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
let (device, queue) = pollster::block_on(
adapter.request_device(&wgpu::DeviceDescriptor {
label: Some("noa-device"),
required_features: wgpu::Features::empty(),
required_limits: wgpu::Limits::default(),
required_limits: wgpu::Limits {
max_texture_dimension_2d: adapter
.limits()
.max_texture_dimension_2d
.min(noa_grid::kitty::MAX_IMAGE_DIM),
..wgpu::Limits::default()
},
experimental_features: wgpu::ExperimentalFeatures::default(),
memory_hints: wgpu::MemoryHints::default(),
trace: wgpu::Trace::Off,
}))
.map_err(|e| format!("could not open a GPU device ({e})"))?;
}),
)
.map_err(|e| format!("could not open a GPU device ({e})"))?;
crate::startup_trace::mark("gpu-device-ready");
Ok(PrewarmedGpu {
instance,
Expand Down Expand Up @@ -794,6 +802,7 @@ impl App {
path_probe_cache: HashMap::new(),
next_path_probe_generation: 0,
search_prompt: None,
search_worker: None,
copy_mode: None,
copy_mode_suppressed_releases: HashSet::new(),
copy_mode_suppressed_repeats: HashSet::new(),
Expand Down
7 changes: 7 additions & 0 deletions crates/noa-app/src/app/event_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,13 @@ impl ApplicationHandler<UserEvent> for App {
title,
text,
} => self.show_file_preview(window_id, pane_id, title, text),
UserEvent::SearchUpdated(window_id, pane_id) => {
if let Some(window_id) = self.resolve_pane_window(window_id, pane_id)
&& let Some(state) = self.windows.get(&window_id)
{
state.window.request_redraw();
}
}
UserEvent::Redraw(window_id, pane_id) => {
#[cfg(target_os = "macos")]
if let Some(panel) = &self.text_panel {
Expand Down
150 changes: 138 additions & 12 deletions crates/noa-app/src/app/input_ops/search.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,36 @@
use super::super::*;
use super::ActiveOverlay;

fn navigate_search(
worker: Option<&crate::search_worker::SearchWorker>,
target: &Arc<parking_lot::Mutex<noa_grid::Terminal>>,
terminal: &mut noa_grid::Terminal,
action: SearchAction,
) {
if worker
.is_some_and(|worker| worker.queue_navigation(target, terminal.screen_generation(), action))
{
return;
}
match action {
SearchAction::FindNext => {
terminal.search_next();
}
SearchAction::FindPrevious => {
terminal.search_previous();
}
_ => unreachable!("only search navigation is handled here"),
}
}

impl App {
pub(in crate::app) fn handle_search_action(&mut self, action: SearchAction) {
let Some((window_id, pane_id)) =
self.resolve_pane_command_target(AppCommand::Search(action))
else {
return;
};
let Some(terminal) = self
let Some(target) = self
.windows
.get(&window_id)
.and_then(|state| state.surfaces.get(&pane_id))
Expand All @@ -17,7 +39,7 @@ impl App {
return;
};

let mut terminal = terminal.lock();
let mut terminal = target.lock();
match action {
SearchAction::Find => {
// Only one prompt is tracked app-wide; cmd+f while one is
Expand Down Expand Up @@ -45,13 +67,15 @@ impl App {
}
return;
}
SearchAction::FindNext => {
terminal.search_next();
SearchAction::FindNext | SearchAction::FindPrevious => {
navigate_search(self.search_worker.as_ref(), &target, &mut terminal, action);
}
SearchAction::FindPrevious => {
terminal.search_previous();
SearchAction::Clear => {
if let Some(worker) = &self.search_worker {
worker.cancel();
Comment on lines +74 to +75

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Scope search cancellation to the target pane

When window A has a pending query and focus moves to window B, invoking the menu or command-palette “Clear Search” targets B but this unconditional call cancels the single app-wide job belonging to A. Window A retains its open prompt and displayed query, yet its result will never publish until the query is edited again; cancellation should verify the target terminal or be maintained per pane.

Useful? React with 👍 / 👎.

}
terminal.clear_search();
}
SearchAction::Clear => terminal.clear_search(),
}
drop(terminal);

Expand Down Expand Up @@ -167,11 +191,33 @@ impl App {
else {
return;
};
{
let mut terminal = terminal.lock();
match effect {
SearchPromptEffect::UpdateQuery(query) => terminal.set_search_query(query),
SearchPromptEffect::ClearQuery => terminal.clear_search(),
match effect {
SearchPromptEffect::UpdateQuery(query) => {
if self.search_worker.is_none() {
match crate::search_worker::SearchWorker::new() {
Ok(worker) => self.search_worker = Some(worker),
Err(err) => {
log::warn!("could not start search worker: {err}");
return;
}
}
}
let proxy = self.proxy.clone();
let screen_generation = terminal.lock().screen_generation();
self.search_worker.as_ref().unwrap().submit(
Arc::downgrade(&terminal),
screen_generation,
query,
move || {
let _ = proxy.send_event(UserEvent::SearchUpdated(window_id, pane_id));
},
);
}
SearchPromptEffect::ClearQuery => {
if let Some(worker) = &self.search_worker {
worker.cancel();
}
terminal.lock().clear_search();
}
}
if let Some(state) = self.windows.get(&window_id) {
Expand All @@ -187,6 +233,9 @@ impl App {
let Some(session) = self.search_prompt.take() else {
return;
};
if clear && let Some(worker) = &self.search_worker {
worker.cancel();
}
if clear
&& let Some(terminal) = self
.windows
Expand All @@ -201,3 +250,80 @@ impl App {
}
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::search_worker::SearchWorker;
use noa_core::GridSize;
use noa_grid::Terminal;
use parking_lot::Mutex;
use std::time::Duration;

#[test]
fn navigation_during_search_matches_synchronous_navigation() {
for (text, old_query, actions) in [
("foo foo", "", vec![SearchAction::FindNext]),
("foo foo foo", "", vec![SearchAction::FindPrevious]),
(
"foo foo foo",
"",
vec![
SearchAction::FindNext,
SearchAction::FindNext,
SearchAction::FindPrevious,
],
),
("foo far foo", "f", vec![SearchAction::FindNext]),
("foo far foo", "f", vec![SearchAction::FindPrevious]),
("absent", "", vec![SearchAction::FindNext]),
] {
let make_terminal = || {
let mut terminal = Terminal::new(GridSize::new(20, 3));
noa_vt::Stream::new().feed(text.as_bytes(), &mut terminal);
if !old_query.is_empty() {
terminal.set_search_query(old_query);
terminal.search_previous();
}
terminal
};
let mut expected = make_terminal();
expected.set_search_query("foo");
for action in &actions {
match action {
SearchAction::FindNext => {
expected.search_next();
}
SearchAction::FindPrevious => {
expected.search_previous();
}
_ => unreachable!(),
}
}
let terminal = Arc::new(Mutex::new(make_terminal()));
let worker = SearchWorker::new().unwrap();
let mut guard = terminal.lock();
let old_search = guard.active().search.clone();
let (tx, rx) = crossbeam_channel::bounded(1);
worker.submit(
Arc::downgrade(&terminal),
guard.screen_generation(),
"foo".into(),
move || {
let _ = tx.send(());
},
);
for action in actions {
navigate_search(Some(&worker), &terminal, &mut guard, action);
}
assert_eq!(guard.active().search, old_search);
drop(guard);
rx.recv_timeout(Duration::from_secs(2)).unwrap();
assert_eq!(
terminal.lock().active().search,
expected.active().search,
"text={text:?}, old_query={old_query:?}"
);
}
}
}
2 changes: 1 addition & 1 deletion crates/noa-app/src/app/input_ops/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ impl App {
reserved,
local.input_echo_seq.clone(),
);
match local.pty_writer.write_owned(stamped) {
match local.pty_writer.write_reserved(stamped) {
Ok(()) => crate::io_thread::QueueInputResult::Queued,
Err(_) => crate::io_thread::QueueInputResult::Disconnected,
}
Expand Down
3 changes: 2 additions & 1 deletion crates/noa-app/src/app/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -809,7 +809,8 @@ impl App {
let kitty_animation_flag = terminal.kitty_animation_flag();
let terminal = Arc::new(Mutex::new(terminal));
let (resize_tx, resize_rx) = crossbeam_channel::unbounded();
let (pty_input_tx, pty_input_rx) = crate::io_thread::input_channel();
let (pty_input_tx, pty_input_rx) =
crate::io_thread::input_channel_with_budget(pty.writer().budget());
let (auto_approve_feedback_tx, auto_approve_feedback_rx) = crossbeam_channel::unbounded();
let auto_approve_guards = Arc::new(Mutex::new(
crate::auto_approve::AutoApproveInputGuards::default(),
Expand Down
13 changes: 10 additions & 3 deletions crates/noa-app/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ pub enum UserEvent {
},
/// New terminal output is available; request a redraw.
Redraw(WindowId, PaneId),
SearchUpdated(WindowId, PaneId),
/// A hover-path existence probe finished on its worker thread
/// (`App::hover_link_target` never stats the filesystem on the main
/// thread — a network volume can block a metadata query indefinitely).
Expand Down Expand Up @@ -133,19 +134,25 @@ pub enum UserEvent {
/// `sendText`) is waiting on the main thread (noa-server spec DEC-C). The
/// payload lives in `App::ipc_pending`, keyed by `request_id`, because
/// `UserEvent` derives `Eq` and so cannot carry a reply channel directly.
IpcAction { request_id: u64 },
IpcAction {
request_id: u64,
},
/// A remote discovery or create-pane worker completed. Panels and worker
/// results remain in `App::remote_pending`; the Eq event carries only its
/// monotonic lookup id and never carries the bearer token.
RemoteRequestCompleted { request_id: u64 },
RemoteRequestCompleted {
request_id: u64,
},
/// Deferred focus restore after a macOS native-tab close. Calling
/// `focus_window()` synchronously from `close_tab` collides with AppKit
/// still transferring key/firstResponder to the sibling tab, leaving the
/// window key but its firstResponder pointed at the NSWindow rather than
/// winit's text-input view — so `keyDown:` never reaches winit and input
/// goes dead. Posting through the proxy re-runs the focus on a fresh
/// event-loop iteration, after AppKit has finished the tab teardown.
RestoreFocus { window_id: WindowId },
RestoreFocus {
window_id: WindowId,
},
}

/// Whether an AppleScript-driven spawn joins the focused window's tab group or
Expand Down
6 changes: 5 additions & 1 deletion crates/noa-app/src/io_thread.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,11 @@ use sidebar::*;
use spawn::*;

pub(crate) use auto_approve::{AutoApproveFeedback, AutoApprovePublish};
pub(crate) use input_queue::{EchoStampedInput, PtyInputQueue, QueueInputResult, input_channel};
#[cfg(test)]
pub(crate) use input_queue::input_channel;
pub(crate) use input_queue::{
EchoStampedInput, PtyInputQueue, QueueInputResult, input_channel_with_budget,
};
pub(crate) use ipc_tap::IpcOutputTap;
pub(crate) use overview::{OverviewPublish, publish_overview_snapshot};
pub(crate) use raw_attach::RawAttachTap;
Expand Down
Loading