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
203 changes: 203 additions & 0 deletions crates/noa-app/examples/native-text-panels.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
//! Explicit GUI smoke check with synthetic content, without a shell/clipboard writes.

#[cfg(target_os = "macos")]
pub use noa_app::{AppCommand, UserEvent, split_tree};
#[cfg(target_os = "macos")]
mod commands {
pub use noa_app::{SearchAction, TerminalAction};
}
#[cfg(target_os = "macos")]
#[path = "../src/text_panel.rs"]
mod text_panel;

#[cfg(target_os = "macos")]
fn main() {
use objc2::{
msg_send,
runtime::{AnyClass, AnyObject},
};
use objc2_foundation::{NSRange, NSString};
use std::time::{Duration, Instant};
use text_panel::{TextPanel, TextPanelMode};
use winit::{
application::ApplicationHandler,
event::WindowEvent,
event_loop::{ActiveEventLoop, ControlFlow, EventLoop, EventLoopProxy},
platform::macos::{ActivationPolicy, EventLoopBuilderExtMacOS},
window::WindowId,
};

const PROMPT: &str =
"日本語の指示を編集します。\n\n> error at src/main.rs:42\n\nPlease fix the regression.";
struct Smoke {
panel: Option<TextPanel>,
proxy: EventLoopProxy<UserEvent>,
phase: usize,
ready: Instant,
focus_deadline: Instant,
}

fn activate_panel(title: &str) {
unsafe {
let app: *mut AnyObject =
msg_send![AnyClass::get(c"NSApplication").unwrap(), sharedApplication];
let _: () = msg_send![app, activateIgnoringOtherApps: true];
let windows: *mut AnyObject = msg_send![app, windows];
let count: usize = msg_send![windows, count];
for index in 0..count {
let window: *mut AnyObject = msg_send![windows, objectAtIndex: index];
let name: objc2::rc::Retained<NSString> = msg_send![window, title];
if name.to_string().starts_with(title) {
let _: () =
msg_send![window, makeKeyAndOrderFront: std::ptr::null::<AnyObject>()];
}
}
}
}

fn check_find_selection(panel: &TextPanel) {
// Exercise the native find field without reading or writing the clipboard.
unsafe {
let app: *mut AnyObject =
msg_send![AnyClass::get(c"NSApplication").unwrap(), sharedApplication];
let window: *mut AnyObject = msg_send![app, keyWindow];
let field: *mut AnyObject = msg_send![window, firstResponder];
let is_editor: bool = msg_send![field, isFieldEditor];
assert!(is_editor, "Find must focus its native field editor");
let query = "日本語 query";
let _: () = msg_send![field, setString: &*NSString::from_str(query)];
assert!(
panel.handle_command(AppCommand::Terminal(commands::TerminalAction::SelectAll))
);
let range: NSRange = msg_send![field, selectedRange];
assert_eq!(range, NSRange::new(0, query.encode_utf16().count()));
for command in [
AppCommand::Preferences,
AppCommand::NewWindow,
AppCommand::NextNotification,
AppCommand::ToggleQuickTerminal,
AppCommand::ToggleScratchTerminal,
] {
assert!(
!panel.handle_command(command),
"modeless panels must let {command:?} reach the app"
);
}
}
}
impl ApplicationHandler<UserEvent> for Smoke {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
unsafe {
let app: *mut AnyObject =
msg_send![AnyClass::get(c"NSApplication").unwrap(), sharedApplication];
let _: () = msg_send![app, activateIgnoringOtherApps: true];
}
self.panel = Some(
TextPanel::open(
"Compose Prompt — Sample / feature/test",
PROMPT,
TextPanelMode::Compose,
WindowId::from(1u64),
split_tree::PaneId::new(1),
None,
self.proxy.clone(),
)
.unwrap(),
);
self.ready = Instant::now() + Duration::from_millis(300);
self.focus_deadline = Instant::now() + Duration::from_secs(10);
event_loop.set_control_flow(ControlFlow::WaitUntil(self.ready));
}
fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {}
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
if self.phase == 6 {
return;
}
if Instant::now() < self.ready {
event_loop.set_control_flow(ControlFlow::WaitUntil(self.ready));
return;
}
let panel = self.panel.as_ref().unwrap();
if self.phase == 0 || self.phase == 2 || self.phase == 4 {
if !panel.handle_command(AppCommand::Search(commands::SearchAction::Find)) {
assert!(
Instant::now() < self.focus_deadline,
"native panel did not gain focus in phase {}",
self.phase
);
activate_panel(match self.phase {
0 => "Compose Prompt",
2 => "Output Snapshot",
_ => "Agent Workflows",
});
self.ready = Instant::now() + Duration::from_millis(100);
event_loop.set_control_flow(ControlFlow::WaitUntil(self.ready));
return;
}
self.phase += 1;
self.ready = Instant::now() + Duration::from_millis(300);
event_loop.set_control_flow(ControlFlow::WaitUntil(self.ready));
} else if self.phase == 1 {
check_find_selection(panel);
assert_eq!(
panel.draft(),
Some((split_tree::PaneId::new(1), PROMPT.to_string()))
);
panel.close();
self.panel = Some(TextPanel::open("Output Snapshot — Sample", "# Result\n\n日本語と English の説明。\n\n```rust\nfn main() {\n println!(\"Hello\");\n}\n```\n\nThe terminal keeps running while this snapshot stays still.",
TextPanelMode::Output, WindowId::from(1u64), split_tree::PaneId::new(1), None, self.proxy.clone()).unwrap());
self.phase = 2;
self.focus_deadline = Instant::now() + Duration::from_secs(10);
self.ready = Instant::now() + Duration::from_millis(300);
event_loop.set_control_flow(ControlFlow::WaitUntil(self.ready));
} else {
check_find_selection(panel);
assert!(panel.draft().is_none());
panel.note_output(split_tree::PaneId::new(1));
panel.close();
if self.phase == 3 {
self.panel = Some(
TextPanel::open(
"Agent Workflows — Sample",
include_str!("../../../docs/AGENT_WORKFLOW.md"),
TextPanelMode::Guide,
WindowId::from(1u64),
split_tree::PaneId::new(1),
None,
self.proxy.clone(),
)
.unwrap(),
);
self.phase = 4;
self.focus_deadline = Instant::now() + Duration::from_secs(10);
self.ready = Instant::now() + Duration::from_millis(300);
event_loop.set_control_flow(ControlFlow::WaitUntil(self.ready));
} else {
self.phase = 6;
event_loop.exit();
}
}
}
}
let event_loop = EventLoop::<UserEvent>::with_user_event()
.with_activation_policy(ActivationPolicy::Regular)
.build()
.unwrap();
let mut smoke = Smoke {
panel: None,
proxy: event_loop.create_proxy(),
phase: 0,
ready: Instant::now(),
focus_deadline: Instant::now(),
};
event_loop.run_app(&mut smoke).unwrap();
assert_eq!(smoke.phase, 6);
println!(
"Native composer, Japanese draft, reader, guide, find routing, and close checks passed."
);
}

#[cfg(not(target_os = "macos"))]
fn main() {
println!("Native text panel smoke check requires macOS.");
}
6 changes: 6 additions & 0 deletions crates/noa-app/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,9 @@ pub struct App {
command_palette: Option<CommandPaletteSession>,
/// The open send-selection target picker, if any.
send_selection_picker: Option<SendSelectionPickerSession>,
#[cfg(target_os = "macos")]
text_panel: Option<crate::text_panel::TextPanel>,
prompt_drafts: HashMap<PaneId, String>,
/// The endpoint/discovery/target-picker overlay for the single
/// `Attach Remote` command-palette flow.
remote_ui: Option<remote_ui::RemoteUiSession>,
Expand Down Expand Up @@ -796,6 +799,9 @@ impl App {
copy_mode_suppressed_repeats: HashSet::new(),
command_palette: None,
send_selection_picker: None,
#[cfg(target_os = "macos")]
text_panel: None,
prompt_drafts: HashMap::new(),
remote_ui: None,
theme_settings: None,
process_monitor: None,
Expand Down
11 changes: 11 additions & 0 deletions crates/noa-app/src/app/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ impl App {
command: AppCommand,
origin: CommandOrigin,
) {
#[cfg(target_os = "macos")]
if self
.text_panel
.as_ref()
.is_some_and(|panel| panel.handle_command(command))
{
return;
}
if overview_should_intercept_command(command, self.overview_visible, origin) {
return;
}
Expand Down Expand Up @@ -190,6 +198,9 @@ impl App {
AppCommand::OpenThemePicker => self.open_theme_settings(ThemeSettingsMode::Theme),
AppCommand::OpenSettings => self.open_theme_settings(ThemeSettingsMode::Settings),
AppCommand::ToggleProcessMonitor => self.toggle_process_monitor(),
AppCommand::NextNotification => self.focus_next_notification(),
AppCommand::ComposePrompt => self.open_text_panel(true),
AppCommand::ReadOutput => self.open_text_panel(false),
AppCommand::ToggleFullscreen => self.toggle_fullscreen(),
AppCommand::ToggleQuickTerminal => self.toggle_quick_terminal(event_loop),
AppCommand::ToggleScratchTerminal => self.toggle_scratch_terminal(event_loop),
Expand Down
2 changes: 2 additions & 0 deletions crates/noa-app/src/app/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ pub struct AppConfig {
pub clipboard_read: noa_config::ClipboardAccess,
/// Whether to confirm before pasting content that could run commands.
pub clipboard_paste_protection: bool,
pub file_link_editor: noa_config::FileLinkEditor,
/// Whether to show a confirmation dialog before quitting the app.
pub confirm_quit: bool,
/// Whether `CSI 21 t` may report the window title back to the program
Expand Down Expand Up @@ -280,6 +281,7 @@ impl AppConfig {
palette: config.palette,
clipboard_read: config.clipboard_read,
clipboard_paste_protection: config.clipboard_paste_protection,
file_link_editor: config.file_link_editor,
confirm_quit: config.confirm_quit,
title_report: config.title_report,
window_padding_x: config.window_padding_x,
Expand Down
52 changes: 39 additions & 13 deletions crates/noa-app/src/app/event_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -259,19 +259,35 @@ impl ApplicationHandler<UserEvent> for App {
};
if crate::notification::should_notify(self.os_focused, window_id) {
crate::notification::post_notification(title.as_deref(), &body);
// The notifying pane (typically an AI agent awaiting the
// user's reply) flags its session card so the sidebar and
// tab overview surface it until the window regains focus
// (FR-16). The OS-focused window is exempt for the same
// reason its desktop notification is suppressed — the user
// is already looking at it, and focus is what clears the
// flag.
self.apply_session_delta(crate::session_store::SessionDelta::Attention {
id: Self::session_card_id(window_id, pane_id),
});
}
// Desktop alerts are window-scoped; unread state is pane-scoped.
self.apply_session_delta(crate::session_store::SessionDelta::Attention {
id: Self::session_card_id(window_id, pane_id),
});
}
UserEvent::TextPanelInput {
window_id,
pane_id,
process,
text,
paste,
} => {
self.handle_text_panel_input(window_id, pane_id, process, text, paste);
}
UserEvent::TextPanelReturn { window_id, pane_id } => {
self.return_from_text_panel(window_id, pane_id)
}
UserEvent::FilePreview {
window_id,
pane_id,
title,
text,
} => self.show_file_preview(window_id, pane_id, title, text),
UserEvent::Redraw(window_id, pane_id) => {
#[cfg(target_os = "macos")]
if let Some(panel) = &self.text_panel {
panel.note_output(pane_id);
}
// P1-1/P1-2: resolve to the pane's current window. This is
// also what neutralizes a `Remote`-transport pane's
// `WinitConnectionNotifier`, which bakes in its `window_id`
Expand Down Expand Up @@ -610,8 +626,7 @@ impl ApplicationHandler<UserEvent> for App {
// apply: expedite the (slow) watcher so the `about_to_wait`
// pass right after this event stats the file immediately.
self.expedite_config_watch();
// A window gaining focus clears its cards' unread bells (FR-11).
self.clear_session_bell_for_window(window_id);
self.clear_focused_session_bell(window_id);
// The native tab bar appears/disappears without a `Resized`
// event (a full-size content view keeps `inner_size` fixed),
// and every tab add/switch/close focuses the surviving
Expand Down Expand Up @@ -1579,7 +1594,18 @@ impl App {
}
match target {
LinkTarget::Uri(uri) => link_open::open_uri(&uri),
LinkTarget::Path(path) => link_open::open_path(&path),
LinkTarget::Path { path, line, column } => {
if self.modifiers.alt_key() {
self.open_file_preview(window_id, path, line);
} else {
link_open::open_path(
&path,
line,
column,
self.config.file_link_editor,
);
}
}
}
return;
}
Expand Down
9 changes: 8 additions & 1 deletion crates/noa-app/src/app/helpers/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,8 @@ pub(crate) fn overview_redraw_decision(
pub(crate) fn command_scope(command: AppCommand) -> CommandScope {
match command {
AppCommand::Copy
| AppCommand::ComposePrompt
| AppCommand::ReadOutput
| AppCommand::Paste
| AppCommand::SendSelectionToPane
| AppCommand::ExportScrollback
Expand All @@ -356,6 +358,7 @@ pub(crate) fn command_scope(command: AppCommand) -> CommandScope {
| AppCommand::SetTabTitle
| AppCommand::CloseTab => CommandScope::FocusedTab,
AppCommand::ToggleTabOverview
| AppCommand::NextNotification
| AppCommand::SelectTab(_)
| AppCommand::NextTab
| AppCommand::PrevTab => CommandScope::NativeTabGroup,
Expand Down Expand Up @@ -416,7 +419,9 @@ pub(crate) fn command_palette_snapshot(

pub(crate) fn overview_command_scope(command: AppCommand) -> CommandScope {
match command {
AppCommand::ToggleTabOverview => CommandScope::NativeTabGroup,
AppCommand::ToggleTabOverview | AppCommand::NextNotification => {
CommandScope::NativeTabGroup
}
AppCommand::About
| AppCommand::Preferences
| AppCommand::EditConfigFile
Expand All @@ -430,6 +435,8 @@ pub(crate) fn overview_command_scope(command: AppCommand) -> CommandScope {
// The palette does not open while the overview is focused (v1, R-10):
// Overview scope makes `ToggleCommandPalette` a no-op there (AC-15).
AppCommand::ToggleCommandPalette
| AppCommand::ComposePrompt
| AppCommand::ReadOutput
| AppCommand::AttachRemote
| AppCommand::OpenThemePicker
| AppCommand::OpenSettings
Expand Down
1 change: 1 addition & 0 deletions crates/noa-app/src/app/input_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ mod process_monitor;
mod search;
mod tab_title;
mod terminal;
mod text_panel;
mod theme_settings;

pub(in crate::app) use copy_mode::{
Expand Down
Loading