From c8ca5796e98b3e8157b446dba5bfc6d523490624 Mon Sep 17 00:00:00 2001 From: "shingo.imota" Date: Sat, 5 Sep 2026 11:26:22 +0900 Subject: [PATCH 1/4] feat(app): improve coding agent workflows Make concurrent agent sessions easier to follow with pane-scoped unread notifications and explicit lifecycle state. Add native prompt drafts and output readers, plus editor line navigation and local previews. Document the opt-in hook and editor setup. --- crates/noa-app/examples/native-text-panels.rs | 170 +++++++ crates/noa-app/src/app.rs | 6 + crates/noa-app/src/app/commands.rs | 11 + crates/noa-app/src/app/config.rs | 2 + crates/noa-app/src/app/event_loop.rs | 52 +- crates/noa-app/src/app/helpers/dispatch.rs | 9 +- crates/noa-app/src/app/input_ops.rs | 1 + crates/noa-app/src/app/input_ops/pointer.rs | 10 +- .../noa-app/src/app/input_ops/text_panel.rs | 280 +++++++++++ .../noa-app/src/app/overview/interaction.rs | 4 +- crates/noa-app/src/app/sidebar.rs | 16 +- crates/noa-app/src/app/sidebar/interaction.rs | 11 + crates/noa-app/src/app/sidebar/model.rs | 14 +- crates/noa-app/src/app/sidebar/state.rs | 96 +++- crates/noa-app/src/app/split_ops.rs | 15 +- crates/noa-app/src/cli.rs | 5 + crates/noa-app/src/command_palette.rs | 11 +- crates/noa-app/src/commands/command.rs | 11 + crates/noa-app/src/commands/keybind.rs | 1 + crates/noa-app/src/commands/tests.rs | 18 + crates/noa-app/src/events.rs | 17 + crates/noa-app/src/io_thread/feed.rs | 2 + crates/noa-app/src/io_thread/spawn.rs | 8 + crates/noa-app/src/lib.rs | 1 + crates/noa-app/src/link_open.rs | 89 +++- crates/noa-app/src/session_store.rs | 161 ++++-- crates/noa-app/src/sidebar.rs | 4 +- .../noa-app/src/split_tree/tree/commands.rs | 3 + crates/noa-app/src/text_panel.rs | 464 ++++++++++++++++++ crates/noa-config/src/lib.rs | 42 ++ crates/noa-config/src/parser/overrides.rs | 12 + crates/noa-config/src/parser/tests.rs | 14 + crates/noa-grid/src/lib.rs | 4 +- crates/noa-grid/src/osc.rs | 54 ++ crates/noa-grid/src/terminal.rs | 6 + crates/noa-grid/src/terminal/handler.rs | 7 + crates/noa-grid/src/tests/osc.rs | 25 + docs/AGENT_WORKFLOW.md | 204 ++++++++ docs/CONFIGURATION.md | 1 + docs/FEATURES.md | 2 + docs/KEYBINDINGS.md | 7 + docs/specs/agent-attention.md | 22 +- docs/specs/agent-workflow.md | 53 ++ scripts/noa-agent-hook.py | 63 +++ scripts/test-native-text-panels.sh | 27 + scripts/test_noa_agent_hook.py | 29 ++ 46 files changed, 1956 insertions(+), 108 deletions(-) create mode 100644 crates/noa-app/examples/native-text-panels.rs create mode 100644 crates/noa-app/src/app/input_ops/text_panel.rs create mode 100644 crates/noa-app/src/text_panel.rs create mode 100644 docs/AGENT_WORKFLOW.md create mode 100644 docs/specs/agent-workflow.md create mode 100644 scripts/noa-agent-hook.py create mode 100644 scripts/test-native-text-panels.sh create mode 100644 scripts/test_noa_agent_hook.py diff --git a/crates/noa-app/examples/native-text-panels.rs b/crates/noa-app/examples/native-text-panels.rs new file mode 100644 index 00000000..c134f87a --- /dev/null +++ b/crates/noa-app/examples/native-text-panels.rs @@ -0,0 +1,170 @@ +//! 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, + proxy: EventLoopProxy, + 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 = msg_send![window, title]; + if name.to_string().starts_with(title) { + let _: () = + msg_send![window, makeKeyAndOrderFront: std::ptr::null::()]; + } + } + } + } + + 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())); + } + } + impl ApplicationHandler 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 == 4 { + 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 { + 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(if self.phase == 0 { + "Compose Prompt" + } else { + "Output Snapshot" + }); + 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(); + self.phase = 4; + event_loop.exit(); + } + } + } + let event_loop = EventLoop::::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, 4); + println!("Native composer, Japanese draft, reader, find routing, and close checks passed."); +} + +#[cfg(not(target_os = "macos"))] +fn main() { + println!("Native text panel smoke check requires macOS."); +} diff --git a/crates/noa-app/src/app.rs b/crates/noa-app/src/app.rs index 85696179..f7d3fb15 100644 --- a/crates/noa-app/src/app.rs +++ b/crates/noa-app/src/app.rs @@ -293,6 +293,9 @@ pub struct App { command_palette: Option, /// The open send-selection target picker, if any. send_selection_picker: Option, + #[cfg(target_os = "macos")] + text_panel: Option, + prompt_drafts: HashMap, /// The endpoint/discovery/target-picker overlay for the single /// `Attach Remote` command-palette flow. remote_ui: Option, @@ -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, diff --git a/crates/noa-app/src/app/commands.rs b/crates/noa-app/src/app/commands.rs index b93f7807..7605f010 100644 --- a/crates/noa-app/src/app/commands.rs +++ b/crates/noa-app/src/app/commands.rs @@ -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; } @@ -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), diff --git a/crates/noa-app/src/app/config.rs b/crates/noa-app/src/app/config.rs index 3d0e9e96..cd9e1745 100644 --- a/crates/noa-app/src/app/config.rs +++ b/crates/noa-app/src/app/config.rs @@ -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 @@ -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, diff --git a/crates/noa-app/src/app/event_loop.rs b/crates/noa-app/src/app/event_loop.rs index ac776ba7..a0f9e3e0 100644 --- a/crates/noa-app/src/app/event_loop.rs +++ b/crates/noa-app/src/app/event_loop.rs @@ -259,19 +259,35 @@ impl ApplicationHandler 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` @@ -610,8 +626,7 @@ impl ApplicationHandler 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 @@ -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; } diff --git a/crates/noa-app/src/app/helpers/dispatch.rs b/crates/noa-app/src/app/helpers/dispatch.rs index e519aa85..0f59aad5 100644 --- a/crates/noa-app/src/app/helpers/dispatch.rs +++ b/crates/noa-app/src/app/helpers/dispatch.rs @@ -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 @@ -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, @@ -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 @@ -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 diff --git a/crates/noa-app/src/app/input_ops.rs b/crates/noa-app/src/app/input_ops.rs index 1b87ff65..2f397795 100644 --- a/crates/noa-app/src/app/input_ops.rs +++ b/crates/noa-app/src/app/input_ops.rs @@ -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::{ diff --git a/crates/noa-app/src/app/input_ops/pointer.rs b/crates/noa-app/src/app/input_ops/pointer.rs index 1c951360..d857136d 100644 --- a/crates/noa-app/src/app/input_ops/pointer.rs +++ b/crates/noa-app/src/app/input_ops/pointer.rs @@ -396,9 +396,7 @@ impl App { /// Resolve the currently Cmd+hovered link in `window_id`'s under-the- /// mouse pane to its open target, re-deriving it from live grid state /// (rather than caching it on `Surface::hover_link`, which the renderer - /// only needs the geometry of). A path target's line/column suffix is - /// dropped here — `open` has no notion of a line number — but it was - /// still part of what got underlined on hover. + /// only needs the geometry of). Keep the line/column for editor navigation. pub(in crate::app) fn open_hovered_link(&self, window_id: WindowId) -> Option { let state = self.windows.get(&window_id)?; let pane_id = state.last_mouse_pane?; @@ -441,7 +439,11 @@ impl App { } let resolved = resolve_hover_path(&path_match.path, cwd.as_deref())?; self.path_probe_confirmed(&resolved) - .then_some(LinkTarget::Path(resolved)) + .then_some(LinkTarget::Path { + path: resolved, + line: path_match.line, + column: path_match.column, + }) } } diff --git a/crates/noa-app/src/app/input_ops/text_panel.rs b/crates/noa-app/src/app/input_ops/text_panel.rs new file mode 100644 index 00000000..1b3b45a9 --- /dev/null +++ b/crates/noa-app/src/app/input_ops/text_panel.rs @@ -0,0 +1,280 @@ +use super::super::*; + +impl App { + pub(in crate::app) fn open_file_preview( + &self, + window_id: WindowId, + path: std::path::PathBuf, + line: Option, + ) { + let Some(pane_id) = self + .windows + .get(&window_id) + .and_then(|state| state.last_mouse_pane) + else { + return; + }; + let proxy = self.proxy.clone(); + std::thread::spawn(move || { + use std::io::Read; + let title = format!( + "File Preview — {}{}", + path.display(), + line.map(|n| format!(":{n}")).unwrap_or_default() + ); + let read = || -> std::io::Result { + use std::os::unix::fs::OpenOptionsExt; + let file = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NONBLOCK) + .open(&path)?; + if !file.metadata()?.is_file() { + return Err(std::io::Error::other( + "preview requires a regular text file", + )); + } + let mut bytes = Vec::new(); + file.take(crate::text_panel::TEXT_LIMIT as u64 + 1) + .read_to_end(&mut bytes)?; + let truncated = bytes.len() > crate::text_panel::TEXT_LIMIT; + if truncated { + bytes.truncate(crate::text_panel::TEXT_LIMIT); + } + let text = match String::from_utf8(bytes) { + Ok(text) => text, + Err(err) if truncated && err.utf8_error().error_len().is_none() => { + let end = err.utf8_error().valid_up_to(); + String::from_utf8(err.into_bytes()[..end].to_vec()).unwrap() + } + Err(_) => return Err(std::io::Error::other("file is not UTF-8 text")), + }; + Ok(if truncated { + format!("[Preview limited to 1 MiB]\n\n{text}") + } else { + text + }) + }; + let text = read().unwrap_or_else(|err| format!("Could not preview this file: {err}")); + let _ = proxy.send_event(UserEvent::FilePreview { + window_id, + pane_id, + title, + text, + }); + }); + } + + pub(in crate::app) fn show_file_preview( + &mut self, + window_id: WindowId, + pane_id: PaneId, + title: String, + text: String, + ) { + #[cfg(target_os = "macos")] + if self.resolve_pane_window(window_id, pane_id).is_some() { + if let Some(panel) = &self.text_panel { + if !panel.can_close() { + return; + } + if let Some((pane, text)) = panel.draft() { + self.prompt_drafts.insert(pane, text); + } + } + match crate::text_panel::TextPanel::open( + &title, + &text, + crate::text_panel::TextPanelMode::File, + window_id, + pane_id, + None, + self.proxy.clone(), + ) { + Ok(panel) => self.text_panel = Some(panel), + Err(err) => log::warn!("could not open file preview: {err}"), + } + } + } + + pub(in crate::app) fn return_from_text_panel(&mut self, window_id: WindowId, pane_id: PaneId) { + let Some(window_id) = self.resolve_pane_window(window_id, pane_id) else { + return; + }; + #[cfg(target_os = "macos")] + if let Some(panel) = &self.text_panel { + panel.close(); + } + self.focus_pane(window_id, pane_id); + self.snap_pane_viewport_to_bottom(window_id, pane_id); + if let Some(state) = self.windows.get(&window_id) { + state.window.focus_window(); + } + } + + pub(in crate::app) fn open_text_panel(&mut self, editable: bool) { + #[cfg(target_os = "macos")] + { + let command = if editable { + AppCommand::ComposePrompt + } else { + AppCommand::ReadOutput + }; + let Some((window_id, pane_id)) = self.resolve_pane_command_target(command) else { + return; + }; + if let Some(panel) = &self.text_panel { + if !panel.can_close() { + return; + } + if let Some((pane, text)) = panel.draft() { + self.prompt_drafts.insert(pane, text); + } + } + let id = Self::session_card_id(window_id, pane_id); + let card = self.session_store.get(&id); + let process = card.and_then(|card| card.process.clone()); + let context = card + .map(|card| { + format!( + "{} · {} · {}", + card.display_name(), + card.branch.as_deref().unwrap_or(""), + card.cwd + ) + }) + .unwrap_or_else(|| "Terminal".to_string()); + let title = format!( + "{} — {}", + if editable { + "Compose Prompt" + } else { + "Output Snapshot" + }, + context + .chars() + .filter(|c| !c.is_control()) + .take(160) + .collect::() + ); + let text = if editable { + self.prompt_drafts + .get(&pane_id) + .filter(|text| !text.is_empty()) + .cloned() + .unwrap_or_else(|| { + self.windows + .get(&window_id) + .and_then(|state| state.surfaces.get(&pane_id)) + .and_then(|surface| surface.terminal.lock().selected_text()) + .map(|selection| quote_selection(&selection)) + .unwrap_or_default() + }) + } else { + let Some(surface) = self + .windows + .get(&window_id) + .and_then(|state| state.surfaces.get(&pane_id)) + else { + return; + }; + let mut terminal = surface.terminal.lock(); + terminal.selected_text().unwrap_or_else(|| { + terminal + .scrollback_text_tail(crate::text_panel::TEXT_LIMIT - 128) + .map(|(text, truncated)| { + if truncated { + format!("[Earlier output omitted]\n\n{text}") + } else { + text + } + }) + .unwrap_or_default() + }) + }; + match crate::text_panel::TextPanel::open( + &title, + &text, + if editable { + crate::text_panel::TextPanelMode::Compose + } else { + crate::text_panel::TextPanelMode::Output + }, + window_id, + pane_id, + process, + self.proxy.clone(), + ) { + Ok(panel) => self.text_panel = Some(panel), + Err(err) => log::warn!("could not open text panel: {err}"), + } + } + #[cfg(not(target_os = "macos"))] + { + let _ = editable; + log::warn!("text panels require macOS"); + } + } + + pub(in crate::app) fn handle_text_panel_input( + &mut self, + window_id: WindowId, + pane_id: PaneId, + process: Option, + text: String, + paste: bool, + ) { + let Some(window_id) = self.resolve_pane_window(window_id, pane_id) else { + return; + }; + let text = crate::text_panel::bounded_text(&text); + self.prompt_drafts.insert(pane_id, text.clone()); + if !paste || text.is_empty() { + return; + } + let current_process = self + .session_store + .get(&Self::session_card_id(window_id, pane_id)) + .and_then(|card| card.process.clone()); + if process != current_process { + #[cfg(target_os = "macos")] + if let Some(panel) = &self.text_panel { + panel.set_title( + "Destination process changed — close and reopen Compose Prompt to review", + ); + } + return; + } + #[cfg(target_os = "macos")] + if let Some(panel) = &self.text_panel { + panel.close(); + } + self.focus_pane(window_id, pane_id); + if let Some(state) = self.windows.get(&window_id) { + state.window.focus_window(); + } + self.paste_text_to_pane_with_confirm_window(window_id, window_id, pane_id, text, false); + } +} + +fn quote_selection(selection: &str) -> String { + let text = crate::text_panel::bounded_text(selection); + let quoted = text + .lines() + .map(|line| format!("> {line}")) + .collect::>() + .join("\n"); + crate::text_panel::bounded_text(&format!("{quoted}\n\n")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn selected_log_is_quoted_without_changing_line_contents() { + assert_eq!( + quote_selection("error: 日本語\n at src/main.rs:42"), + "> error: 日本語\n> at src/main.rs:42\n\n" + ); + } +} diff --git a/crates/noa-app/src/app/overview/interaction.rs b/crates/noa-app/src/app/overview/interaction.rs index c1402b53..677cad8b 100644 --- a/crates/noa-app/src/app/overview/interaction.rs +++ b/crates/noa-app/src/app/overview/interaction.rs @@ -338,11 +338,11 @@ impl App { ) -> Option { let card_id = Self::session_card_id(tile_id.window_id, tile_id.pane_id); let card = self.session_store.get(&card_id)?; - if card.attention { + if card.attention || card.agent_needs_attention() { Some(crate::chrome::palette().dot_red) } else if card.unread_bell { Some(crate::chrome::palette().dot_yellow) - } else if card.busy { + } else if card.is_running() { Some(crate::chrome::palette().dot_blue) } else { None diff --git a/crates/noa-app/src/app/sidebar.rs b/crates/noa-app/src/app/sidebar.rs index 597ed87e..6265ada7 100644 --- a/crates/noa-app/src/app/sidebar.rs +++ b/crates/noa-app/src/app/sidebar.rs @@ -26,23 +26,21 @@ use std::collections::HashSet; /// `Rename` only ever target real windows, so they pass through unconditionally /// (and dropping a QT `Remove` would be harmless anyway). /// -/// A `Bell`/`Attention` for the window that holds OS focus is also dropped -/// (`window_os_focused`), mirroring the OSC 9/777 suppression (FR-16): the -/// user is looking at that window, and focus is the only thing that clears the -/// flags, so applying them would leave a marker nothing clears until the -/// window loses and regains focus. +/// Only the selected pane in the OS-focused window is already being read. +/// Notifications from its sibling panes must still reach the sidebar. fn session_delta_should_apply( delta: &SessionDelta, window_eligible: bool, - window_os_focused: bool, + pane_os_focused: bool, ) -> bool { match delta { SessionDelta::Upsert { .. } | SessionDelta::Progress { .. } + | SessionDelta::AgentStatus { .. } | SessionDelta::ProgressComplete { .. } | SessionDelta::ProgressError { .. } => window_eligible, SessionDelta::Bell { .. } | SessionDelta::Attention { .. } => { - window_eligible && !window_os_focused + window_eligible && !pane_os_focused } SessionDelta::Remove { .. } | SessionDelta::Branch { .. } @@ -198,7 +196,7 @@ fn card_progress_bar(card: &SessionCard) -> Option { /// The status rail along a card's left edge. Precedence mirrors /// [`status_dot`]: attention > bell > busy > idle. fn card_status_rail(card: &SessionCard) -> Option { - if card.attention { + if card.attention || card.agent_needs_attention() { return Some(StatusRail { kind: StatusRailKind::Attention, color: chrome().dot_red, @@ -210,7 +208,7 @@ fn card_status_rail(card: &SessionCard) -> Option { color: chrome().dot_yellow, }); } - if card.busy { + if card.is_running() { return Some(StatusRail { kind: StatusRailKind::Activity, color: chrome().dot_blue, diff --git a/crates/noa-app/src/app/sidebar/interaction.rs b/crates/noa-app/src/app/sidebar/interaction.rs index 08022d61..cc41b11d 100644 --- a/crates/noa-app/src/app/sidebar/interaction.rs +++ b/crates/noa-app/src/app/sidebar/interaction.rs @@ -1,6 +1,17 @@ use super::*; impl App { + pub(in crate::app) fn focus_next_notification(&mut self) { + let Some(window_id) = self.focused else { + return; + }; + let windows = self.session_windows_for_window(window_id); + if let Some(card) = self.session_store.next_notification(&windows) { + self.hide_tab_overview(); + self.focus_session_card(card); + } + } + /// Route a left-press at `point` (physical px) that lands in the focused /// window's sidebar band. Returns `true` when the click was consumed, so /// the caller stops before the terminal/split handling sees it (the diff --git a/crates/noa-app/src/app/sidebar/model.rs b/crates/noa-app/src/app/sidebar/model.rs index 8fd09684..4c1a8bf9 100644 --- a/crates/noa-app/src/app/sidebar/model.rs +++ b/crates/noa-app/src/app/sidebar/model.rs @@ -526,7 +526,19 @@ fn emit_card_text( } else { process_badge(&lines.process, card.busy) }; - let (badge, badge_fg) = if card.attention { + let (badge, badge_fg) = if let Some(status) = &card.agent_status { + let detail = if status.detail.is_empty() { + String::new() + } else { + format!(" · {}", status.detail) + }; + let fg = if status.state.needs_attention() { + chrome().dot_red + } else { + badge_fg + }; + (format!("{badge} · {}{detail}", status.state.label()), fg) + } else if card.attention { (format!("{badge} · {ATTENTION_LABEL}"), chrome().dot_red) } else { (badge, badge_fg) diff --git a/crates/noa-app/src/app/sidebar/state.rs b/crates/noa-app/src/app/sidebar/state.rs index 5b73fbed..8ff7ba12 100644 --- a/crates/noa-app/src/app/sidebar/state.rs +++ b/crates/noa-app/src/app/sidebar/state.rs @@ -26,7 +26,10 @@ fn progress_flash_for_delta(delta: &SessionDelta) -> Option<(ProgressFlashKind, fn delta_changes_overview_label(delta: &SessionDelta) -> bool { matches!( delta, - SessionDelta::Bell { .. } | SessionDelta::Attention { .. } | SessionDelta::Progress { .. } + SessionDelta::Bell { .. } + | SessionDelta::Attention { .. } + | SessionDelta::Progress { .. } + | SessionDelta::AgentStatus { .. } ) } @@ -66,9 +69,8 @@ impl App { /// (FR-14/AC-16b): a QT pane shares the app-wide publish gate, so without /// this guard its output would leak a card into every window's sidebar /// whenever a sidebar is open elsewhere. Because the card never enters, no - /// reconcile is needed when the quick terminal is torn down. A bell or - /// attention request for the OS-focused window is dropped by the same gate - /// (FR-16 parity with the OSC 9/777 path — focus is what clears the flags). + /// reconcile is needed when the quick terminal is torn down. Only the + /// pane currently being read is exempt from unread notifications. pub(in crate::app) fn apply_session_delta(&mut self, delta: SessionDelta) { let window_id = WindowId::from(delta.id().window_id.0); // An agent session's bell is an interaction request, not a generic beep @@ -78,7 +80,7 @@ impl App { if !session_delta_should_apply( &delta, self.window_sidebar_eligible(window_id), - self.os_focused == Some(window_id), + self.session_card_is_focused(delta.id()), ) { return; } @@ -128,6 +130,21 @@ impl App { _ => None, }; let pane_id = delta.id().pane_id; + let agent_notification = match &delta { + SessionDelta::AgentStatus { + id, + status: Some(status), + .. + } if status.state.needs_attention() + || status.state == noa_grid::AgentState::Finished => + { + self.session_store + .get(id) + .filter(|card| card.agent_status.as_ref() != Some(status)) + .map(|_| *id) + } + _ => None, + }; // panel-metrics-view FR-7: a metrics tick refreshes the open // process-monitor overlay's rows (checked before `apply` moves the // delta) — a no-op when the overlay is closed. @@ -137,6 +154,9 @@ impl App { // which reads the store, not the delta stream). let is_process_delta = matches!(delta, SessionDelta::Process { .. }); self.session_store.apply(delta); + if let Some(id) = agent_notification { + self.apply_session_delta(SessionDelta::Attention { id }); + } if is_metrics_delta { self.refresh_process_monitor(); } @@ -295,6 +315,21 @@ impl App { /// window-remove reach it transitively via `close_pane`/`close_tab`. pub(in crate::app) fn reconcile_session_store(&mut self) { let live = self.live_session_card_ids(); + let live_panes: HashSet<_> = self + .windows + .values() + .flat_map(|state| state.surfaces.keys().copied()) + .collect(); + self.prompt_drafts + .retain(|pane, _| live_panes.contains(pane)); + #[cfg(target_os = "macos")] + if self + .text_panel + .as_ref() + .is_some_and(|panel| !live_panes.contains(&panel.pane_id())) + { + self.text_panel = None; + } self.session_store.reconcile_sessions(&live); // Prune foreground-process probes for torn-down sessions at the same // choke point, so a closed pane's dup'd fd is released. @@ -313,26 +348,33 @@ impl App { }) { self.sidebar_rename = None; } + if let Some(window_id) = self.os_focused { + self.clear_focused_session_bell(window_id); + } } - /// Clear the unread-bell and attention flags on every card of a - /// just-focused window (FR-11/FR-16). Called from the `Focused(true)` - /// handler. The window's overview tiles re-stamp their labels so a cleared - /// attention marker disappears from the overview too. - pub(in crate::app) fn clear_session_bell_for_window(&mut self, window_id: WindowId) { - self.session_store - .clear_bell_for_window(SessionWindowId(u64::from(window_id))); - // The store already cleared the persistent attention flags; discard any - // remaining arrival emphasis for the same window as well. - let sw = SessionWindowId(u64::from(window_id)); - self.attention_flash_until - .retain(|id, _| id.window_id != sw); - self.request_sidebar_redraw(); - if let Some(pane_id) = self.windows.get(&window_id).map(|state| state.focused_pane) { - self.mark_overview_label_dirty(OverviewTileId::new(window_id, pane_id)); - } else { - self.request_overview_redraw(); + fn session_card_is_focused(&self, id: SessionCardId) -> bool { + let window_id = WindowId::from(id.window_id.0); + !self.overview_visible + && self.os_focused == Some(window_id) + && self + .windows + .get(&window_id) + .is_some_and(|state| state.focused_pane == id.pane_id) + } + + pub(in crate::app) fn clear_focused_session_bell(&mut self, window_id: WindowId) { + let Some(pane_id) = self.windows.get(&window_id).map(|state| state.focused_pane) else { + return; + }; + let id = Self::session_card_id(window_id, pane_id); + if !self.session_card_is_focused(id) { + return; } + self.session_store.clear_bell_for_card(id); + self.attention_flash_until.remove(&id); + self.request_sidebar_redraw(); + self.mark_overview_label_dirty(OverviewTileId::new(window_id, pane_id)); } /// Whether a window may host a sidebar (FR-14, scratch-terminal R6): @@ -514,6 +556,16 @@ impl App { mod tests { use super::*; + #[test] + fn unread_notifications_are_suppressed_only_for_the_selected_pane() { + let id = SessionCardId::new(SessionWindowId(1), PaneId::new(2)); + for delta in [SessionDelta::Bell { id }, SessionDelta::Attention { id }] { + assert!(session_delta_should_apply(&delta, true, false)); + assert!(!session_delta_should_apply(&delta, true, true)); + assert!(!session_delta_should_apply(&delta, false, false)); + } + } + #[test] fn repeated_attention_does_not_restart_one_shot_emphasis() { let id = SessionCardId::new(SessionWindowId(1), PaneId::new(2)); diff --git a/crates/noa-app/src/app/split_ops.rs b/crates/noa-app/src/app/split_ops.rs index d5ea8fe6..c9152084 100644 --- a/crates/noa-app/src/app/split_ops.rs +++ b/crates/noa-app/src/app/split_ops.rs @@ -736,7 +736,19 @@ impl App { let Some(state) = self.windows.get(&window_id) else { return; }; - if !state.contains_pane(pane_id) || state.focused_pane == pane_id { + if !state.contains_pane(pane_id) { + return; + } + let already_focused = state.focused_pane == pane_id; + if state.zoomed.is_some_and(|zoomed| zoomed != pane_id) { + // Acknowledgement must not hide a notification behind another zoomed pane. + if let Some(state) = self.windows.get_mut(&window_id) { + state.zoomed = Some(pane_id); + } + self.relayout_and_resize_window(window_id); + } + if already_focused { + self.clear_focused_session_bell(window_id); return; } self.end_copy_mode_for_window(window_id); @@ -776,6 +788,7 @@ impl App { } } self.update_focused_ime_cursor_area(window_id); + self.clear_focused_session_bell(window_id); if let Some(state) = self.windows.get(&window_id) { state.window.request_redraw(); } diff --git a/crates/noa-app/src/cli.rs b/crates/noa-app/src/cli.rs index 8f826513..be7d3680 100644 --- a/crates/noa-app/src/cli.rs +++ b/crates/noa-app/src/cli.rs @@ -286,6 +286,11 @@ fn show_config_output(config: &StartupConfig) -> String { &config.clipboard_paste_protection.to_string(), ); push_line(&mut out, "confirm-quit", &config.confirm_quit.to_string()); + push_line( + &mut out, + "file-link-editor", + config.file_link_editor.as_str(), + ); push_line(&mut out, "title-report", &config.title_report.to_string()); push_optional_line(&mut out, "window-padding-x", config.window_padding_x); push_optional_line(&mut out, "window-padding-y", config.window_padding_y); diff --git a/crates/noa-app/src/command_palette.rs b/crates/noa-app/src/command_palette.rs index 90b7f8e9..5d3e8c28 100644 --- a/crates/noa-app/src/command_palette.rs +++ b/crates/noa-app/src/command_palette.rs @@ -120,6 +120,9 @@ pub(crate) fn command_palette_title(command: AppCommand) -> &'static str { AppCommand::OpenThemePicker => "Open Theme\u{2026}", AppCommand::OpenSettings => "Open Settings\u{2026}", AppCommand::ToggleProcessMonitor => "Open Process Monitor", + AppCommand::NextNotification => "Go to Next Unread Notification", + AppCommand::ComposePrompt => "Compose Prompt", + AppCommand::ReadOutput => "Read Output Snapshot", } } @@ -139,6 +142,9 @@ pub(crate) fn command_palette_entries() -> &'static [AppCommand] { AppCommand::OpenThemePicker, AppCommand::OpenSettings, AppCommand::ToggleProcessMonitor, + AppCommand::NextNotification, + AppCommand::ComposePrompt, + AppCommand::ReadOutput, AppCommand::ReloadConfig, AppCommand::Copy, AppCommand::Paste, @@ -351,10 +357,12 @@ pub(crate) fn command_category(command: AppCommand) -> CommandCategory { | AppCommand::ReloadConfig | AppCommand::Quit => CommandCategory::Application, AppCommand::Copy + | AppCommand::ComposePrompt | AppCommand::Paste | AppCommand::SendSelectionToPane | AppCommand::Terminal(TerminalAction::SelectAll) => CommandCategory::Clipboard, AppCommand::ExportScrollback + | AppCommand::ReadOutput | AppCommand::PipeScrollbackToPager | AppCommand::DiscardRestoredHistory | AppCommand::CheckpointScrollback => CommandCategory::Scroll, @@ -380,7 +388,8 @@ pub(crate) fn command_category(command: AppCommand) -> CommandCategory { | AppCommand::NextTab | AppCommand::PrevTab | AppCommand::SelectTab(_) - | AppCommand::ToggleTabOverview => CommandCategory::Tabs, + | AppCommand::ToggleTabOverview + | AppCommand::NextNotification => CommandCategory::Tabs, AppCommand::NewWindow | AppCommand::CloseWindow => CommandCategory::Window, AppCommand::ToggleCommandPalette | AppCommand::ToggleQuickTerminal diff --git a/crates/noa-app/src/commands/command.rs b/crates/noa-app/src/commands/command.rs index 5b2947c9..8eb61194 100644 --- a/crates/noa-app/src/commands/command.rs +++ b/crates/noa-app/src/commands/command.rs @@ -89,6 +89,9 @@ pub enum AppCommand { /// (v1 scope: Open Questions), so it carries no menu id either (mirrors /// `SelectTab`'s `menu_id() -> ""`). ToggleProcessMonitor, + NextNotification, + ComposePrompt, + ReadOutput, } /// Config-addressable copy-mode entry actions. `CursorOnly` implements the @@ -268,6 +271,8 @@ impl AppCommand { AppCommand::OpenThemePicker => Self::OPEN_THEME_PICKER_MENU_ID, AppCommand::OpenSettings => Self::OPEN_SETTINGS_MENU_ID, AppCommand::ToggleProcessMonitor => "", + AppCommand::NextNotification => "", + AppCommand::ComposePrompt | AppCommand::ReadOutput => "", AppCommand::NextTab => Self::NEXT_TAB_MENU_ID, AppCommand::PrevTab => Self::PREV_TAB_MENU_ID, AppCommand::SetTabTitle => Self::SET_TAB_TITLE_MENU_ID, @@ -449,6 +454,9 @@ impl AppCommand { Self::OpenThemePicker => "theme.open", Self::OpenSettings => "settings.open", Self::ToggleProcessMonitor => "process-monitor.toggle", + Self::NextNotification => "session.next-notification", + Self::ComposePrompt => "agent.compose-prompt", + Self::ReadOutput => "terminal.read-output", } } @@ -531,6 +539,9 @@ impl AppCommand { "theme.open" | "theme-settings.open" => Some(Self::OpenThemePicker), "settings.open" => Some(Self::OpenSettings), "process-monitor.toggle" => Some(Self::ToggleProcessMonitor), + "session.next-notification" => Some(Self::NextNotification), + "agent.compose-prompt" => Some(Self::ComposePrompt), + "terminal.read-output" => Some(Self::ReadOutput), _ => None, } } diff --git a/crates/noa-app/src/commands/keybind.rs b/crates/noa-app/src/commands/keybind.rs index fdb9f618..c1c37c7d 100644 --- a/crates/noa-app/src/commands/keybind.rs +++ b/crates/noa-app/src/commands/keybind.rs @@ -51,6 +51,7 @@ impl Default for KeybindEngine { ("cmd+c", AppCommand::Copy), ("cmd+v", AppCommand::Paste), ("cmd+shift+m", AppCommand::SendSelectionToPane), + ("cmd+shift+j", AppCommand::NextNotification), ("cmd+k", AppCommand::Terminal(TerminalAction::Clear)), ("cmd+a", AppCommand::Terminal(TerminalAction::SelectAll)), ("cmd+=", AppCommand::FontSize(FontSizeAction::Increase)), diff --git a/crates/noa-app/src/commands/tests.rs b/crates/noa-app/src/commands/tests.rs index e9630d47..13fce373 100644 --- a/crates/noa-app/src/commands/tests.rs +++ b/crates/noa-app/src/commands/tests.rs @@ -644,3 +644,21 @@ fn keybind_parser_rejects_missing_or_unknown_key() { Err(KeybindParseError::UnknownKey(_)) )); } +#[test] +fn agent_workflow_actions_round_trip_and_next_notification_has_a_shortcut() { + for (action, command) in [ + ("session.next-notification", AppCommand::NextNotification), + ("agent.compose-prompt", AppCommand::ComposePrompt), + ("terminal.read-output", AppCommand::ReadOutput), + ] { + assert_eq!(command.action_name(), action); + assert_eq!(AppCommand::from_action_name(action), Some(command)); + } + assert_eq!( + KeybindEngine::default().resolve( + &Key::Character("j".into()), + ModifiersState::SUPER | ModifiersState::SHIFT + ), + Some(AppCommand::NextNotification) + ); +} diff --git a/crates/noa-app/src/events.rs b/crates/noa-app/src/events.rs index 51b45ea8..b59da588 100644 --- a/crates/noa-app/src/events.rs +++ b/crates/noa-app/src/events.rs @@ -11,6 +11,23 @@ use winit::window::WindowId; pub enum UserEvent { /// A native app menu item or app-level shortcut was activated. AppCommand(AppCommand), + TextPanelInput { + window_id: WindowId, + pane_id: PaneId, + process: Option, + text: String, + paste: bool, + }, + TextPanelReturn { + window_id: WindowId, + pane_id: PaneId, + }, + FilePreview { + window_id: WindowId, + pane_id: PaneId, + title: String, + text: String, + }, /// An OSC 52 clipboard write was accepted by the terminal policy. ClipboardWrite { window_id: WindowId, diff --git a/crates/noa-app/src/io_thread/feed.rs b/crates/noa-app/src/io_thread/feed.rs index bf43ee46..f45626c2 100644 --- a/crates/noa-app/src/io_thread/feed.rs +++ b/crates/noa-app/src/io_thread/feed.rs @@ -56,6 +56,7 @@ pub(super) struct TerminalOutput { pub(super) pending_clipboard_writes: Vec, pub(super) pending_clipboard_reads: Vec, pub(super) pending_notifications: Vec, + pub(super) agent_status: Option>, /// Last `OSC 9;4` change parsed in this batch, if any. pub(super) progress_update: Option, /// Last completion/error cue in this batch. A trailing Clear preserves the @@ -322,6 +323,7 @@ pub(super) fn feed_terminal_batch>( pending_clipboard_writes: term.take_pending_clipboard_writes(), pending_clipboard_reads: term.take_pending_clipboard_reads(), pending_notifications: term.take_pending_notifications(), + agent_status: term.take_pending_agent_status(), progress_update: term.take_pending_progress_update(), progress_cue: term.take_pending_progress_cue(), synchronized_output: term.modes.synchronized_output(), diff --git a/crates/noa-app/src/io_thread/spawn.rs b/crates/noa-app/src/io_thread/spawn.rs index 8d504680..6c476828 100644 --- a/crates/noa-app/src/io_thread/spawn.rs +++ b/crates/noa-app/src/io_thread/spawn.rs @@ -474,6 +474,14 @@ pub fn spawn( break; // event loop gone } } + if let Some(status) = output.agent_status.take() { + let _ = + proxy.send_event(UserEvent::SessionDelta(SessionDelta::AgentStatus { + id: current_card_target(&window_id, pane_id).1, + status, + at: crate::localtime::wall_clock_now(), + })); + } if let Some(cue) = progress_cue { let id = current_card_target(&window_id, pane_id).1; let delta = match cue { diff --git a/crates/noa-app/src/lib.rs b/crates/noa-app/src/lib.rs index a0a63ba1..126ec3ae 100644 --- a/crates/noa-app/src/lib.rs +++ b/crates/noa-app/src/lib.rs @@ -47,6 +47,7 @@ mod sidebar; pub mod split_tree; pub mod startup_trace; mod tab_switch_trace; +mod text_panel; mod theme; mod theme_favorites; mod theme_settings; diff --git a/crates/noa-app/src/link_open.rs b/crates/noa-app/src/link_open.rs index e88b0b50..067f36d7 100644 --- a/crates/noa-app/src/link_open.rs +++ b/crates/noa-app/src/link_open.rs @@ -5,13 +5,15 @@ /// The Cmd+click open target the hover-link machinery resolved: either an /// OSC 8 / auto-detected URI (dispatched through the scheme allowlist below) -/// or a resolved, existing filesystem path (dispatched through -/// [`open_path`]'s existence re-check instead — paths have no scheme to -/// allowlist). +/// or a filesystem path already resolved by the asynchronous hover probe. #[derive(Clone, PartialEq, Eq, Debug)] pub enum LinkTarget { Uri(String), - Path(std::path::PathBuf), + Path { + path: std::path::PathBuf, + line: Option, + column: Option, + }, } const ALLOWED_SCHEMES: [&str; 3] = ["http", "https", "mailto"]; @@ -49,23 +51,92 @@ pub fn open_uri(uri: &str) { /// (the same reason the hover probe runs on a worker); a path that vanished /// between hover and click just makes `open` exit non-zero, logged from the /// wait below on its own detached thread. -pub fn open_path(path: &std::path::Path) { +pub fn open_path( + path: &std::path::Path, + line: Option, + column: Option, + editor: noa_config::FileLinkEditor, +) { let path = path.to_owned(); - std::thread::spawn( - move || match std::process::Command::new("open").arg(&path).status() { + std::thread::spawn(move || { + if editor != noa_config::FileLinkEditor::Default && path.is_file() { + let result = std::process::Command::new(editor.as_str()) + .args(editor_arguments(&path, line, column, editor)) + .status(); + match result { + Ok(status) if status.success() => return, + Ok(status) => log::warn!( + "file-link editor {} exited with {status}; using default application", + editor.as_str() + ), + Err(err) => log::warn!( + "could not launch file-link editor {}: {err}; using default application", + editor.as_str() + ), + } + } + match std::process::Command::new("open").arg(&path).status() { Ok(status) if !status.success() => { log::warn!("`open` failed for path {} ({status})", path.display()); } Ok(_) => {} Err(err) => log::warn!("failed to open path {}: {err}", path.display()), - }, - ); + } + }); +} + +fn editor_arguments( + path: &std::path::Path, + line: Option, + column: Option, + editor: noa_config::FileLinkEditor, +) -> Vec { + let mut args = Vec::new(); + let mut location = path.as_os_str().to_owned(); + if let Some(line) = line { + if matches!( + editor, + noa_config::FileLinkEditor::Code | noa_config::FileLinkEditor::Cursor + ) { + args.push("--goto".into()); + } + location.push(format!(":{}", line.max(1))); + if let Some(column) = column { + location.push(format!(":{}", column.max(1))); + } + } + args.push(location); + args } #[cfg(test)] mod tests { use super::*; + #[test] + fn editor_receives_exact_location_as_one_argument() { + use noa_config::FileLinkEditor; + use std::ffi::OsString; + let path = std::path::Path::new("/tmp/日本語 project/$(touch nope);file.rs"); + assert_eq!( + editor_arguments(path, Some(42), Some(7), FileLinkEditor::Code), + vec![ + OsString::from("--goto"), + OsString::from("/tmp/日本語 project/$(touch nope);file.rs:42:7") + ] + ); + assert_eq!( + editor_arguments(path, None, Some(7), FileLinkEditor::Cursor), + vec![path.as_os_str().to_owned()] + ); + assert_eq!( + editor_arguments(path, Some(0), Some(0), FileLinkEditor::Zed), + vec![OsString::from( + "/tmp/日本語 project/$(touch nope);file.rs:1:1" + )] + ); + } + #[test] fn allows_http_https_and_mailto() { assert!(is_allowed_uri("http://example.com")); diff --git a/crates/noa-app/src/session_store.rs b/crates/noa-app/src/session_store.rs index 1944479e..1b7ff808 100644 --- a/crates/noa-app/src/session_store.rs +++ b/crates/noa-app/src/session_store.rs @@ -101,7 +101,7 @@ pub struct WallClock { /// The status dot color for a card (FR-11). Semantics: `Blue` = busy (a /// program is running), `Green` = idle, `Yellow` = an unread bell is pending, -/// `Red` = the program requested user interaction (OSC 9/777) and is waiting. +/// `Red` = an unread notification or an explicitly reported request/error. /// Precedence is attention > bell > busy > idle (see [`status_dot`]). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum StatusDot { @@ -134,14 +134,14 @@ pub struct SessionCard { pub branch: Option, pub icon: IconKind, pub unread_bell: bool, - /// The running program posted a desktop notification (OSC 9/777) while the - /// window was unfocused — typically an AI agent (Claude Code / Codex / agy) - /// waiting for the user's reply. Cleared, like `unread_bell`, when the - /// card's window gains focus. + /// Unread notification for this pane; acknowledged when this pane is selected. + /// A notification alone does not imply that the process is waiting for input. pub attention: bool, pub busy: bool, /// Task progress reported by the foreground application via `OSC 9;4`. pub progress: Option, + pub agent_status: Option, + pub agent_status_at: Option, /// Per-tab agent-prompt auto approval is enabled for this card's tab. pub auto_approve_enabled: bool, /// Rolling audit of injected approvals, capped by the store. @@ -172,6 +172,16 @@ pub struct SessionCard { } impl SessionCard { + pub fn is_running(&self) -> bool { + self.agent_status.as_ref().map_or(self.busy, |report| { + report.state == noa_grid::AgentState::Running + }) + } + pub fn agent_needs_attention(&self) -> bool { + self.agent_status + .as_ref() + .is_some_and(|report| report.state.needs_attention()) + } /// The name to display: the user rename if present, else the shell title. pub fn display_name(&self) -> &str { self.name_override.as_deref().unwrap_or(&self.name) @@ -211,13 +221,15 @@ pub enum SessionDelta { /// Apply a user rename (FR-7): sets `name_override`, which survives later /// `Upsert`s. Rename { id: SessionCardId, name: String }, - /// Mark an unread bell (FR-11). Cleared by the main thread when the card's - /// window gains focus. + /// Mark an unread bell (FR-11). Cleared when the pane is selected. Bell { id: SessionCardId }, - /// Mark a pending interaction request (FR-16): the running program posted a - /// desktop notification (OSC 9/777) while its window was unfocused. Cleared - /// alongside bells when the card's window gains focus. + /// Mark an unread notification, acknowledged alongside this pane's bells. Attention { id: SessionCardId }, + AgentStatus { + id: SessionCardId, + status: Option, + at: WallClock, + }, /// Replace or clear the pane's `OSC 9;4` task progress. Progress { id: SessionCardId, @@ -256,6 +268,7 @@ impl SessionDelta { | SessionDelta::Rename { id, .. } | SessionDelta::Bell { id } | SessionDelta::Attention { id } + | SessionDelta::AgentStatus { id, .. } | SessionDelta::Progress { id, .. } | SessionDelta::ProgressComplete { id } | SessionDelta::ProgressError { id } @@ -294,6 +307,9 @@ impl SessionDelta { SessionDelta::Rename { name, .. } => SessionDelta::Rename { id, name }, SessionDelta::Bell { .. } => SessionDelta::Bell { id }, SessionDelta::Attention { .. } => SessionDelta::Attention { id }, + SessionDelta::AgentStatus { status, at, .. } => { + SessionDelta::AgentStatus { id, status, at } + } SessionDelta::Progress { progress, .. } => SessionDelta::Progress { id, progress }, SessionDelta::ProgressComplete { .. } => SessionDelta::ProgressComplete { id }, SessionDelta::ProgressError { .. } => SessionDelta::ProgressError { id }, @@ -490,18 +506,12 @@ impl SessionStore { .collect() } - /// Clear the unread-bell and pending-attention flags on every card - /// belonging to `window_id` (FR-11/FR-16). Called by the main thread when - /// that window gains focus, so a bell or interaction request raised while - /// the window was in the background stops flagging its cards once the user - /// is looking at them. Not a [`SessionDelta`]: the main thread owns the - /// store and clears directly. - pub fn clear_bell_for_window(&mut self, window_id: SessionWindowId) { - for (id, card) in self.cards.iter_mut() { - if id.window_id == window_id { - card.unread_bell = false; - card.attention = false; - } + /// Acknowledge only the selected pane. Other panes in the same window + /// may still have unread notifications, including hidden zoomed panes. + pub fn clear_bell_for_card(&mut self, id: SessionCardId) { + if let Some(card) = self.cards.get_mut(&id) { + card.unread_bell = false; + card.attention = false; } } @@ -537,6 +547,17 @@ impl SessionStore { self.cards.values().filter(|card| card.attention).count() } + /// Use the same stable ordering as the sidebar and stay inside its window group. + pub fn next_notification(&self, windows: &HashSet) -> Option { + self.ordered_ids_for_windows(windows) + .into_iter() + .find(|id| { + self.cards + .get(id) + .is_some_and(|card| card.attention || card.unread_bell) + }) + } + /// The `(busy, attention)` counts among cards whose `window_id` is in /// `windows` (per-window sidebar header counts, R5) — the filtered /// counterpart of [`busy_count`](Self::busy_count)/[`attention_count`](Self::attention_count). @@ -609,6 +630,8 @@ impl SessionStore { attention: false, busy, progress: None, + agent_status: None, + agent_status_at: None, auto_approve_enabled: false, auto_approve_audit: VecDeque::new(), process: None, @@ -651,6 +674,21 @@ impl SessionStore { card.attention = true; } } + SessionDelta::AgentStatus { id, status, at } => { + if let Some(card) = self.cards.get_mut(&id) { + if card.agent_status != status { + card.agent_status_at = status.as_ref().map(|_| at); + } + if status + .as_ref() + .is_none_or(|report| report.state == noa_grid::AgentState::Running) + { + card.attention = false; + card.unread_bell = false; + } + card.agent_status = status; + } + } SessionDelta::Progress { id, progress } => { if let Some(card) = self.cards.get_mut(&id) { card.progress = progress; @@ -780,11 +818,11 @@ impl SessionStore { /// attention > bell > busy > idle: a pending interaction request wins over an /// unread bell, which wins over a running program, which wins over idle. pub fn status_dot(card: &SessionCard) -> StatusDot { - if card.attention { + if card.attention || card.agent_needs_attention() { StatusDot::Red } else if card.unread_bell { StatusDot::Yellow - } else if card.busy { + } else if card.is_running() { StatusDot::Blue } else { StatusDot::Green @@ -1179,6 +1217,8 @@ mod tests { attention: false, busy: false, progress: None, + agent_status: None, + agent_status_at: None, auto_approve_enabled: false, auto_approve_audit: VecDeque::new(), process: None, @@ -1260,7 +1300,7 @@ mod tests { assert!(store.get(&id).unwrap().attention); store.apply(SessionDelta::Bell { id }); - store.clear_bell_for_window(id.window_id); + store.clear_bell_for_card(id); let card = store.get(&id).unwrap(); assert!(!card.attention); assert!(!card.unread_bell); @@ -1494,7 +1534,7 @@ mod tests { vec![card_id(1, 1), card_id(2, 1), card_id(1, 2)] ); - store.clear_bell_for_window(SessionWindowId(1)); + store.clear_bell_for_card(card_id(1, 1)); assert_eq!(store.attention_count(), 0); assert_eq!( store.ordered_ids(), @@ -1717,17 +1757,70 @@ mod tests { } #[test] - fn clear_bell_for_window_only_clears_that_window() { + fn acknowledging_a_pane_preserves_other_panes_and_windows() { let mut store = SessionStore::new(); - let (a, b) = (card_id(1, 1), card_id(2, 1)); - store.apply(upsert(a, 1, "a")); - store.apply(upsert(b, 1, "b")); - store.apply(SessionDelta::Bell { id: a }); - store.apply(SessionDelta::Bell { id: b }); + let (a, b, c) = (card_id(1, 1), card_id(1, 2), card_id(2, 1)); + for id in [a, b, c] { + store.apply(upsert(id, 1, "agent")); + store.apply(SessionDelta::Bell { id }); + store.apply(SessionDelta::Attention { id }); + } - store.clear_bell_for_window(SessionWindowId(1)); + store.clear_bell_for_card(a); assert!(!store.get(&a).unwrap().unread_bell); - assert!(store.get(&b).unwrap().unread_bell); + assert!(!store.get(&a).unwrap().attention); + for id in [b, c] { + assert!(store.get(&id).unwrap().unread_bell); + assert!(store.get(&id).unwrap().attention); + } + store.clear_bell_for_card(card_id(99, 99)); + assert_eq!(store.attention_count(), 2); + } + + #[test] + fn next_notification_visits_unread_panes_only_in_the_current_group() { + let mut store = SessionStore::new(); + let (a, b, c) = (card_id(1, 1), card_id(1, 2), card_id(2, 1)); + for id in [a, b, c] { + store.apply(upsert(id, 1, "agent")); + } + store.apply(SessionDelta::Attention { id: a }); + store.apply(SessionDelta::Bell { id: b }); + store.apply(SessionDelta::Attention { id: c }); + let windows = [SessionWindowId(1)].into_iter().collect(); + assert_eq!(store.next_notification(&windows), Some(a)); + store.clear_bell_for_card(a); + assert_eq!(store.next_notification(&windows), Some(b)); + store.clear_bell_for_card(b); + assert_eq!(store.next_notification(&windows), None); + assert!(store.get(&c).unwrap().attention); + } + + #[test] + fn acknowledging_agent_notification_does_not_resolve_its_request() { + let mut store = SessionStore::new(); + let id = card_id(1, 1); + store.apply(upsert(id, 1, "agent")); + let status = noa_grid::AgentStatus { + state: noa_grid::AgentState::Permission, + detail: "Edit".into(), + }; + store.apply(SessionDelta::AgentStatus { + id, + status: Some(status.clone()), + at: wall(10, 0), + }); + store.apply(SessionDelta::Attention { id }); + store.clear_bell_for_card(id); + store.apply(upsert(id, 2, "agent")); + assert_eq!(store.get(&id).unwrap().agent_status, Some(status)); + assert!(!store.get(&id).unwrap().attention); + store.apply(SessionDelta::AgentStatus { + id, + status: None, + at: wall(10, 1), + }); + assert!(store.get(&id).unwrap().agent_status.is_none()); } #[test] diff --git a/crates/noa-app/src/sidebar.rs b/crates/noa-app/src/sidebar.rs index d82052b0..fe9befde 100644 --- a/crates/noa-app/src/sidebar.rs +++ b/crates/noa-app/src/sidebar.rs @@ -680,7 +680,9 @@ pub fn card_lines( }); // A busy card's age is always "now"; showing it is noise. Idle cards keep // the relative time (how long since this session last did anything). - let updated = if card.busy { + let updated = if card.agent_needs_attention() { + format_relative_time(now, card.agent_status_at.unwrap_or(card.updated_at)) + } else if card.busy { String::new() } else { format_relative_time(now, card.updated_at) diff --git a/crates/noa-app/src/split_tree/tree/commands.rs b/crates/noa-app/src/split_tree/tree/commands.rs index 41629307..35068dbb 100644 --- a/crates/noa-app/src/split_tree/tree/commands.rs +++ b/crates/noa-app/src/split_tree/tree/commands.rs @@ -21,6 +21,8 @@ pub fn resolve_pane_command_target( ) -> Option { match command { AppCommand::Copy + | AppCommand::ComposePrompt + | AppCommand::ReadOutput | AppCommand::Paste | AppCommand::SendSelectionToPane | AppCommand::ExportScrollback @@ -53,6 +55,7 @@ pub fn resolve_pane_command_target( | AppCommand::OpenThemePicker | AppCommand::OpenSettings | AppCommand::ToggleProcessMonitor + | AppCommand::NextNotification | AppCommand::ToggleFullscreen | AppCommand::ToggleQuickTerminal | AppCommand::ToggleScratchTerminal diff --git a/crates/noa-app/src/text_panel.rs b/crates/noa-app/src/text_panel.rs new file mode 100644 index 00000000..7b51c08f --- /dev/null +++ b/crates/noa-app/src/text_panel.rs @@ -0,0 +1,464 @@ +//! Modeless native text editing/reading, independent of the live terminal grid. + +pub(crate) const TEXT_LIMIT: usize = 1024 * 1024; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum TextPanelMode { + Compose, + Output, + File, +} + +pub(crate) fn bounded_text(text: &str) -> String { + let mut end = text.len().min(TEXT_LIMIT); + while !text.is_char_boundary(end) { + end -= 1; + } + text[..end].to_owned() +} + +/// Ranges use UTF-16 coordinates, as required by NSTextView. +pub(crate) fn code_blocks(text: &str) -> Vec> { + let mut blocks = Vec::new(); + let mut fence: Option<(char, usize, usize)> = None; + let mut offset = 0; + for line in text.split_inclusive('\n') { + let len = line.encode_utf16().count(); + let trimmed = line.trim_start_matches(' '); + if line.len() - trimmed.len() <= 3 + && let Some(mark @ ('`' | '~')) = trimmed.chars().next() + { + let width = trimmed.chars().take_while(|c| *c == mark).count(); + if width >= 3 { + if let Some((open_mark, open_width, begin)) = fence { + if mark == open_mark + && width >= open_width + && trimmed[width..].trim().is_empty() + { + blocks.push(begin..offset); + fence = None; + } + } else { + fence = Some((mark, width, offset + len)); + } + } + } + offset += len; + } + if let Some((_, _, begin)) = fence { + blocks.push(begin..offset); + } + blocks +} + +#[cfg(target_os = "macos")] +pub(crate) use native::TextPanel; + +#[cfg(target_os = "macos")] +mod native { + use std::cell::Cell; + use std::io; + + use objc2::rc::Retained; + use objc2::runtime::{AnyClass, AnyObject, NSObject}; + use objc2::{AnyThread, DefinedClass, define_class, msg_send, sel}; + use objc2_foundation::{NSPoint, NSRange, NSRect, NSSize, NSString}; + use winit::event_loop::EventLoopProxy; + use winit::window::WindowId; + + use crate::{AppCommand, UserEvent, split_tree::PaneId}; + + struct Ivars { + proxy: EventLoopProxy, + window_id: WindowId, + pane_id: PaneId, + process: Option, + text_view: Retained, + editable: bool, + mode: super::TextPanelMode, + blocks: Vec>, + block_index: Cell, + } + + define_class!( + // SAFETY: NSObject has no subclassing requirements. App owns this + // delegate and accesses all AppKit objects exclusively on the main thread. + #[unsafe(super(NSObject))] + #[name = "NoaTextPanelDelegate"] + #[ivars = Ivars] + struct Delegate; + + impl Delegate { + #[unsafe(method(pasteToPane:))] + fn paste_to_pane(&self, _sender: &AnyObject) { + let marked: bool = unsafe { msg_send![&*self.ivars().text_view, hasMarkedText] }; + if marked { return; } + self.send(true); + } + + #[unsafe(method(returnToPane:))] + fn return_to_pane(&self, _sender: &AnyObject) { + let ivars = self.ivars(); + let _ = ivars.proxy.send_event(UserEvent::TextPanelReturn { window_id: ivars.window_id, pane_id: ivars.pane_id }); + } + + #[unsafe(method(windowWillClose:))] + fn window_will_close(&self, _notification: &AnyObject) { + self.send(false); + } + + #[unsafe(method(windowShouldClose:))] + fn window_should_close(&self, _sender: &AnyObject) -> bool { + self.check_length() + } + + #[unsafe(method(copyCode:))] + fn copy_code(&self, _sender: &AnyObject) { + let ivars = self.ivars(); + if ivars.blocks.is_empty() { return; } + let index = ivars.block_index.get() % ivars.blocks.len(); + let block = &ivars.blocks[index]; + let range = NSRange::new(block.start, block.end - block.start); + // SAFETY: ranges were computed from this immutable text in UTF-16. + unsafe { + let _: () = msg_send![&*ivars.text_view, setSelectedRange: range]; + let _: () = msg_send![&*ivars.text_view, scrollRangeToVisible: range]; + let _: () = msg_send![&*ivars.text_view, copy: std::ptr::null::()]; + } + ivars.block_index.set(index + 1); + } + } + ); + + impl Delegate { + fn text(&self) -> String { + // SAFETY: the retained NSTextView owns the NSString returned by string. + let value: Retained = unsafe { msg_send![&*self.ivars().text_view, string] }; + value.to_string() + } + + fn check_length(&self) -> bool { + if self.ivars().editable && self.text().len() > super::TEXT_LIMIT { + unsafe { + let window: *mut AnyObject = msg_send![&*self.ivars().text_view, window]; + let _: () = msg_send![window, setTitle: &*NSString::from_str("Prompt exceeds 1 MiB — shorten it before pasting or closing")]; + } + return false; + } + true + } + + fn send(&self, paste: bool) { + let ivars = self.ivars(); + if !ivars.editable { + return; + } + if !self.check_length() { + return; + } + let _ = ivars.proxy.send_event(UserEvent::TextPanelInput { + window_id: ivars.window_id, + pane_id: ivars.pane_id, + process: ivars.process.clone(), + text: self.text(), + paste, + }); + } + } + + pub(crate) struct TextPanel { + window: Retained, + delegate: Retained, + find_button: Retained, + stale: Cell, + title: String, + } + + unsafe fn owned(object: *mut AnyObject) -> io::Result> { + unsafe { Retained::from_raw(object) } + .ok_or_else(|| io::Error::other("could not create native text view")) + } + + impl TextPanel { + pub(crate) fn open( + title: &str, + text: &str, + mode: super::TextPanelMode, + window_id: WindowId, + pane_id: PaneId, + process: Option, + proxy: EventLoopProxy, + ) -> io::Result { + let editable = mode == super::TextPanelMode::Compose; + let class = |name| { + AnyClass::get(name) + .ok_or_else(|| io::Error::other("native text panel is unavailable")) + }; + let panel_class = class(c"NSPanel")?; + let scroll_class = class(c"NSScrollView")?; + let text_class = class(c"NSTextView")?; + let button_class = class(c"NSButton")?; + let font_class = class(c"NSFont")?; + let rect = |x, y, w, h| NSRect::new(NSPoint::new(x, y), NSSize::new(w, h)); + let text = super::bounded_text(text); + + // SAFETY: main-thread App command dispatch; selectors and their + // argument/return types are documented AppKit APIs. Retained values + // own +1 alloc/init results; views are also retained by their parents. + unsafe { + let allocated: *mut AnyObject = msg_send![panel_class, alloc]; + let window = owned(msg_send![allocated, + initWithContentRect: rect(0.0, 0.0, 760.0, 520.0), + styleMask: 11_usize, backing: 2_usize, defer: false])?; + let _: () = msg_send![&*window, setReleasedWhenClosed: false]; + let _: () = msg_send![&*window, setFloatingPanel: false]; + let _: () = msg_send![&*window, setTitle: &*NSString::from_str(title)]; + let _: () = msg_send![&*window, setMinSize: NSSize::new(640.0, 300.0)]; + let content: *mut AnyObject = msg_send![&*window, contentView]; + let allocated: *mut AnyObject = msg_send![scroll_class, alloc]; + let scroll = + owned(msg_send![allocated, initWithFrame: rect(16.0, 56.0, 728.0, 448.0)])?; + let _: () = msg_send![&*scroll, setAutoresizingMask: 18_usize]; + let _: () = msg_send![&*scroll, setHasVerticalScroller: true]; + let _: () = msg_send![&*scroll, setBorderType: 1_usize]; + let allocated: *mut AnyObject = msg_send![text_class, alloc]; + let text_view = + owned(msg_send![allocated, initWithFrame: rect(0.0, 0.0, 708.0, 448.0)])?; + let _: () = msg_send![&*text_view, setEditable: editable]; + let _: () = msg_send![&*text_view, setSelectable: true]; + let _: () = msg_send![&*text_view, setRichText: false]; + let _: () = msg_send![&*text_view, setAllowsUndo: editable]; + let _: () = msg_send![&*text_view, setUsesFindBar: true]; + let _: () = msg_send![&*text_view, setAutomaticQuoteSubstitutionEnabled: false]; + let _: () = msg_send![&*text_view, setAutomaticDashSubstitutionEnabled: false]; + let _: () = msg_send![&*text_view, setAutomaticTextReplacementEnabled: false]; + let _: () = msg_send![&*text_view, setVerticallyResizable: true]; + let _: () = msg_send![&*text_view, setHorizontallyResizable: false]; + let _: () = msg_send![&*text_view, setAutoresizingMask: 2_usize]; + let _: () = msg_send![&*text_view, setMaxSize: NSSize::new(f64::MAX, f64::MAX)]; + let _: () = msg_send![&*text_view, setTextContainerInset: NSSize::new(12.0, 12.0)]; + let container: *mut AnyObject = msg_send![&*text_view, textContainer]; + let _: () = msg_send![container, setWidthTracksTextView: true]; + let _: () = msg_send![container, setContainerSize: NSSize::new(708.0, f64::MAX)]; + let font: *mut AnyObject = + msg_send![font_class, monospacedSystemFontOfSize: 14.0_f64, weight: 0.0_f64]; + let _: () = msg_send![&*text_view, setFont: font]; + let _: () = msg_send![&*text_view, setString: &*NSString::from_str(&text)]; + let _: () = msg_send![&*scroll, setDocumentView: &*text_view]; + let _: () = msg_send![content, addSubview: &*scroll]; + let blocks = if mode == super::TextPanelMode::File { + vec![0..text.encode_utf16().count()] + } else { + super::code_blocks(&text) + }; + if mode == super::TextPanelMode::Output { + let prose: *mut AnyObject = msg_send![font_class, systemFontOfSize: 15.0_f64]; + let _: () = msg_send![&*text_view, setFont: prose]; + for block in &blocks { + let _: () = msg_send![&*text_view, setFont: font, range: NSRange::new(block.start, block.end - block.start)]; + } + let mut offset = 0; + for line in text.split_inclusive('\n') { + let length = line.encode_utf16().count(); + if (line.starts_with("# ") + || line.starts_with("## ") + || line.starts_with("### ")) + && !blocks.iter().any(|block| block.contains(&offset)) + { + let heading: *mut AnyObject = + msg_send![font_class, boldSystemFontOfSize: 18.0_f64]; + let _: () = msg_send![&*text_view, setFont: heading, range: NSRange::new(offset, length)]; + } + offset += length; + } + } + let allocated = Delegate::alloc().set_ivars(Ivars { + proxy, + window_id, + pane_id, + process, + text_view: text_view.clone(), + editable, + mode, + blocks, + block_index: Cell::new(0), + }); + let delegate: Retained = msg_send![super(allocated), init]; + let _: () = msg_send![&*window, setDelegate: &*delegate]; + + let allocated: *mut AnyObject = msg_send![button_class, alloc]; + let find = + owned(msg_send![allocated, initWithFrame: rect(16.0, 14.0, 100.0, 30.0)])?; + let _: () = msg_send![&*find, setTitle: &*NSString::from_str("Find")]; + let _: () = msg_send![&*find, setBezelStyle: 1_usize]; + let _: () = msg_send![&*find, setTag: 1_isize]; + let _: () = msg_send![&*find, setTarget: &*text_view]; + let _: () = msg_send![&*find, setAction: sel!(performFindPanelAction:)]; + let _: () = msg_send![content, addSubview: &*find]; + + if !editable { + let allocated: *mut AnyObject = msg_send![button_class, alloc]; + let latest = + owned(msg_send![allocated, initWithFrame: rect(125.0, 14.0, 160.0, 30.0)])?; + let _: () = + msg_send![&*latest, setTitle: &*NSString::from_str("Return to Latest")]; + let _: () = msg_send![&*latest, setBezelStyle: 1_usize]; + let _: () = msg_send![&*latest, setTarget: &*delegate]; + let _: () = msg_send![&*latest, setAction: sel!(returnToPane:)]; + let _: () = msg_send![content, addSubview: &*latest]; + } + + let allocated: *mut AnyObject = msg_send![button_class, alloc]; + let action = + owned(msg_send![allocated, initWithFrame: rect(490.0, 14.0, 250.0, 30.0)])?; + let _: () = msg_send![&*action, setAutoresizingMask: 1_usize]; + let _: () = msg_send![&*action, setTitle: &*NSString::from_str(if editable { "Paste to Pane" } else if mode == super::TextPanelMode::File { "Copy File" } else { "Copy Next Code Block" })]; + let _: () = msg_send![&*action, setBezelStyle: 1_usize]; + let _: () = msg_send![&*action, setEnabled: editable || !delegate.ivars().blocks.is_empty()]; + let _: () = msg_send![&*action, setTarget: &*delegate]; + let _: () = msg_send![&*action, setAction: if editable { sel!(pasteToPane:) } else { sel!(copyCode:) }]; + let _: () = msg_send![content, addSubview: &*action]; + let _: () = msg_send![&*window, center]; + let _: () = + msg_send![&*window, makeKeyAndOrderFront: std::ptr::null::()]; + let _: bool = msg_send![&*window, makeFirstResponder: &*text_view]; + Ok(Self { + window, + delegate, + find_button: find, + stale: Cell::new(false), + title: title.to_string(), + }) + } + } + + pub(crate) fn draft(&self) -> Option<(PaneId, String)> { + self.delegate + .ivars() + .editable + .then(|| (self.delegate.ivars().pane_id, self.delegate.text())) + } + + pub(crate) fn can_close(&self) -> bool { + self.delegate.check_length() + } + + pub(crate) fn close(&self) { + if !self.can_close() { + return; + } + unsafe { + let _: () = msg_send![&*self.window, close]; + } + } + + pub(crate) fn set_title(&self, title: &str) { + unsafe { + let _: () = msg_send![&*self.window, setTitle: &*NSString::from_str(title)]; + } + } + + pub(crate) fn pane_id(&self) -> PaneId { + self.delegate.ivars().pane_id + } + + pub(crate) fn note_output(&self, pane: PaneId) { + if pane == self.pane_id() + && self.delegate.ivars().mode == super::TextPanelMode::Output + && !self.stale.replace(true) + { + self.set_title(&format!("{} — New output available", self.title)); + } + } + + pub(crate) fn handle_command(&self, command: AppCommand) -> bool { + use crate::commands::{SearchAction, TerminalAction}; + // Menus are shared with winit windows; route editing to the native + // first responder while this panel is key, never to the terminal. + unsafe { + let key: bool = msg_send![&*self.window, isKeyWindow]; + if !key { + return false; + } + let view = &*self.delegate.ivars().text_view; + let nil = std::ptr::null::(); + let application: *mut AnyObject = + msg_send![AnyClass::get(c"NSApplication").unwrap(), sharedApplication]; + match command { + AppCommand::Copy => { + let _: bool = + msg_send![application, sendAction: sel!(copy:), to: nil, from: nil]; + } + AppCommand::Paste => { + let _: bool = msg_send![application, sendAction: sel!(pasteAsPlainText:), to: nil, from: nil]; + } + AppCommand::Terminal(TerminalAction::SelectAll) => { + let _: bool = msg_send![application, sendAction: sel!(selectAll:), to: nil, from: nil]; + } + AppCommand::Search(action) => { + let tag = match action { + SearchAction::FindNext => 2_isize, + SearchAction::FindPrevious => 3, + _ => 1, + }; + let _: () = msg_send![&*self.find_button, setTag: tag]; + let _: () = msg_send![view, performFindPanelAction: &*self.find_button]; + let _: () = msg_send![&*self.find_button, setTag: 1_isize]; + } + AppCommand::CloseTab | AppCommand::CloseWindow => self.close(), + AppCommand::Quit | AppCommand::About => return false, + _ => {} + } + true + } + } + } + + impl Drop for TextPanel { + fn drop(&mut self) { + // Clear the weak native delegate before releasing the Rust owner. + unsafe { + let _: () = msg_send![&*self.window, setDelegate: std::ptr::null::()]; + let _: () = msg_send![&*self.window, close]; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bounded_drafts_keep_utf8_intact() { + let text = "あ".repeat(TEXT_LIMIT); + let result = bounded_text(&text); + assert!(result.len() <= TEXT_LIMIT); + assert!(text.starts_with(&result)); + assert_eq!(result.len() % 3, 0); + } + + #[test] + fn code_block_copy_excludes_fences_and_uses_utf16() { + let text = "🦀\n```rust\nlet x = 1;\n```\n"; + let blocks = code_blocks(text); + let utf16: Vec = text.encode_utf16().collect(); + assert_eq!(blocks.len(), 1); + assert_eq!( + String::from_utf16(&utf16[blocks[0].clone()]).unwrap(), + "let x = 1;\n" + ); + assert!(code_blocks("no code").is_empty()); + } + + #[test] + fn code_blocks_preserve_shorter_fences_inside_a_block() { + let text = "````markdown\n```rust\nx\n```\n````\n~~~\ny\n~~~\n"; + let utf16: Vec<_> = text.encode_utf16().collect(); + let blocks: Vec<_> = code_blocks(text) + .into_iter() + .map(|range| String::from_utf16(&utf16[range]).unwrap()) + .collect(); + assert_eq!(blocks, ["```rust\nx\n```\n", "y\n"]); + } +} diff --git a/crates/noa-config/src/lib.rs b/crates/noa-config/src/lib.rs index b891b9ae..0b28264f 100644 --- a/crates/noa-config/src/lib.rs +++ b/crates/noa-config/src/lib.rs @@ -631,6 +631,8 @@ pub struct StartupConfig { /// Whether to confirm before pasting content that could run commands /// (`clipboard-paste-protection`). Ghostty default is on. pub clipboard_paste_protection: bool, + /// Editor command for Cmd-clicking local paths, including line/column. + pub file_link_editor: FileLinkEditor, /// `confirm-quit`: whether app quit (`cmd+q`, menu, command palette) /// prompts before exiting. Default is on. pub confirm_quit: bool, @@ -910,6 +912,7 @@ impl Default for StartupConfig { palette: Vec::new(), clipboard_read: ClipboardAccess::default(), clipboard_paste_protection: true, + file_link_editor: FileLinkEditor::Default, confirm_quit: true, title_report: false, window_padding_x: None, @@ -980,6 +983,40 @@ impl Default for StartupConfig { } } +/// Editor for local file links. Each name selects a fixed argument format. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum FileLinkEditor { + #[default] + Default, + Code, + Cursor, + Zed, + Subl, +} + +impl FileLinkEditor { + pub fn parse(value: &str) -> Option { + match value { + "default" => Some(Self::Default), + "code" => Some(Self::Code), + "cursor" => Some(Self::Cursor), + "zed" => Some(Self::Zed), + "subl" => Some(Self::Subl), + _ => None, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Default => "default", + Self::Code => "code", + Self::Cursor => "cursor", + Self::Zed => "zed", + Self::Subl => "subl", + } + } +} + /// Optional values from a config file or explicit CLI flags. #[derive(Default, Clone, PartialEq)] pub struct ConfigOverrides { @@ -992,6 +1029,7 @@ pub struct ConfigOverrides { pub palette: Vec, pub clipboard_read: Option, pub clipboard_paste_protection: Option, + pub file_link_editor: Option, pub confirm_quit: Option, pub title_report: Option, pub window_padding_x: Option, @@ -1078,6 +1116,7 @@ macro_rules! impl_redacted_config_debug { &self.clipboard_paste_protection, ) .field("confirm_quit", &self.confirm_quit) + .field("file_link_editor", &self.file_link_editor) .field("title_report", &self.title_report) .field("window_padding_x", &self.window_padding_x) .field("window_padding_y", &self.window_padding_y) @@ -1191,6 +1230,7 @@ impl ConfigOverrides { .clipboard_paste_protection .or(self.clipboard_paste_protection), confirm_quit: higher_priority.confirm_quit.or(self.confirm_quit), + file_link_editor: higher_priority.file_link_editor.or(self.file_link_editor), title_report: higher_priority.title_report.or(self.title_report), window_padding_x: higher_priority.window_padding_x.or(self.window_padding_x), window_padding_y: higher_priority.window_padding_y.or(self.window_padding_y), @@ -1343,6 +1383,7 @@ impl ConfigOverrides { .clipboard_paste_protection .unwrap_or(base.clipboard_paste_protection), confirm_quit: self.confirm_quit.unwrap_or(base.confirm_quit), + file_link_editor: self.file_link_editor.unwrap_or(base.file_link_editor), title_report: self.title_report.unwrap_or(base.title_report), window_padding_x: self.window_padding_x.or(base.window_padding_x), window_padding_y: self.window_padding_y.or(base.window_padding_y), @@ -1795,6 +1836,7 @@ mod tests { palette: Vec::new(), clipboard_read: ClipboardAccess::Ask, clipboard_paste_protection: true, + file_link_editor: FileLinkEditor::Default, confirm_quit: true, title_report: false, window_padding_x: None, diff --git a/crates/noa-config/src/parser/overrides.rs b/crates/noa-config/src/parser/overrides.rs index b7024b45..80fd109e 100644 --- a/crates/noa-config/src/parser/overrides.rs +++ b/crates/noa-config/src/parser/overrides.rs @@ -28,6 +28,7 @@ pub(crate) fn build_overrides( let mut palette = Vec::new(); let mut clipboard_read = None; let mut clipboard_paste_protection = None; + let mut file_link_editor = None; let mut confirm_quit = None; let mut title_report = None; let mut window_padding_x = None; @@ -378,6 +379,15 @@ pub(crate) fn build_overrides( "audible-bell-dock-bounce" => { audible_bell_dock_bounce = parse_bool_directive(path, directive, &mut diagnostics); } + "file-link-editor" => { + if let Some(value) = directive.value.as_deref() { + if let Some(editor) = crate::FileLinkEditor::parse(value) { + file_link_editor = Some(editor); + } else { + diagnostics.push(invalid_value_diagnostic(path, &directive.key, value)); + } + } + } "auto-approve" => { auto_approve = parse_bool_directive(path, directive, &mut diagnostics); } @@ -442,6 +452,7 @@ pub(crate) fn build_overrides( palette, clipboard_read, clipboard_paste_protection, + file_link_editor, confirm_quit, title_report, window_padding_x, @@ -554,6 +565,7 @@ pub(crate) fn is_supported_scalar_key(key: &str) -> bool { | "theme" | "clipboard-read" | "clipboard-paste-protection" + | "file-link-editor" | "confirm-quit" | "title-report" | "window-padding-x" diff --git a/crates/noa-config/src/parser/tests.rs b/crates/noa-config/src/parser/tests.rs index 09bfb162..5a004f89 100644 --- a/crates/noa-config/src/parser/tests.rs +++ b/crates/noa-config/src/parser/tests.rs @@ -423,6 +423,20 @@ fn clipboard_paste_protection_parses_bool() { assert!(diagnostics.is_empty()); } +#[test] +fn file_link_editor_validates_fixed_editors_and_merges_overrides() { + use crate::FileLinkEditor; + let (base, diagnostics) = parse_overrides(path(), "file-link-editor = code"); + assert!(diagnostics.is_empty()); + let (higher, diagnostics) = parse_overrides(path(), "file-link-editor = zed"); + assert!(diagnostics.is_empty()); + let config = base.merge(higher).apply_to(crate::StartupConfig::default()); + assert_eq!(config.file_link_editor, FileLinkEditor::Zed); + let (invalid, diagnostics) = parse_overrides(path(), "file-link-editor = sh -c bad"); + assert!(invalid.file_link_editor.is_none()); + assert!(!diagnostics.is_empty()); +} + #[test] fn confirm_quit_parses_bool() { let (overrides, diagnostics) = parse_overrides(path(), "confirm-quit = false"); diff --git a/crates/noa-grid/src/lib.rs b/crates/noa-grid/src/lib.rs index dd7882ba..5f3ba8bd 100644 --- a/crates/noa-grid/src/lib.rs +++ b/crates/noa-grid/src/lib.rs @@ -42,8 +42,8 @@ pub use kitty_keyboard::{ pub use kitty_placeholder::{PLACEHOLDER, PlaceholderRun, scan_row}; pub use modes::ModeState; pub use osc::{ - Notification, Osc52Policy, ProgressCue, ProgressUpdate, ProgressValue, TerminalColors, - TerminalProgress, + AgentState, AgentStatus, Notification, Osc52Policy, ProgressCue, ProgressUpdate, ProgressValue, + TerminalColors, TerminalProgress, }; pub use path::{PathMatch, detect_path_at_column}; pub use screen::{KittyPlacement, Screen, VisibleKittyPlacement}; diff --git a/crates/noa-grid/src/osc.rs b/crates/noa-grid/src/osc.rs index b91e4c7c..9be44749 100644 --- a/crates/noa-grid/src/osc.rs +++ b/crates/noa-grid/src/osc.rs @@ -343,6 +343,60 @@ pub struct Notification { pub body: String, } +/// Explicit, informational agent state. It never authorizes terminal input. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AgentState { + Running, + Permission, + Input, + Finished, + Error, +} + +impl AgentState { + pub const fn label(self) -> &'static str { + match self { + Self::Running => "running", + Self::Permission => "approval needed", + Self::Input => "reply needed", + Self::Finished => "response ended", + Self::Error => "error", + } + } + + pub const fn needs_attention(self) -> bool { + matches!(self, Self::Permission | Self::Input | Self::Error) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AgentStatus { + pub state: AgentState, + pub detail: String, +} + +/// Noa extension: `OSC 777;noa-agent;; ST`. +/// The outer Option distinguishes an invalid report from an explicit clear. +pub(crate) fn parse_agent_status_osc(data: &[u8]) -> Option> { + let rest = std::str::from_utf8(data.strip_prefix(b"777;noa-agent;")?).ok()?; + let (state, detail) = rest.split_once(';').unwrap_or((rest, "")); + let state = match state { + "clear" if detail.is_empty() => return Some(None), + "running" => AgentState::Running, + "permission" => AgentState::Permission, + "input" => AgentState::Input, + "finished" => AgentState::Finished, + "error" => AgentState::Error, + _ => return None, + }; + let detail = detail + .chars() + .filter(|c| !c.is_control()) + .take(160) + .collect(); + Some(Some(AgentStatus { state, detail })) +} + /// A validated determinate progress percentage reported by `OSC 9;4`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct ProgressValue(u8); diff --git a/crates/noa-grid/src/terminal.rs b/crates/noa-grid/src/terminal.rs index a43101e1..861a6119 100644 --- a/crates/noa-grid/src/terminal.rs +++ b/crates/noa-grid/src/terminal.rs @@ -119,6 +119,7 @@ pub struct Terminal { /// [`Terminal::take_pending_notifications`]. Bounded at /// [`NOTIFICATION_QUEUE_CAP`]; the oldest is evicted on overflow. pending_notifications: VecDeque, + pending_agent_status: Option>, /// Current `OSC 9;4` task state and its last-write-wins app-layer delta. progress: Option, pending_progress_update: Option, @@ -203,6 +204,7 @@ impl Terminal { pending_clipboard_writes: Vec::new(), pending_clipboard_reads: Vec::new(), pending_notifications: VecDeque::new(), + pending_agent_status: None, progress: None, pending_progress_update: None, pending_progress_cue: None, @@ -801,6 +803,10 @@ impl Terminal { self.pending_notifications.drain(..).collect() } + pub fn take_pending_agent_status(&mut self) -> Option> { + self.pending_agent_status.take() + } + /// Current task progress reported by the foreground terminal application. pub const fn progress(&self) -> Option { self.progress diff --git a/crates/noa-grid/src/terminal/handler.rs b/crates/noa-grid/src/terminal/handler.rs index 19096fb1..a60b92fe 100644 --- a/crates/noa-grid/src/terminal/handler.rs +++ b/crates/noa-grid/src/terminal/handler.rs @@ -492,6 +492,7 @@ impl Handler for Terminal { self.pending_clipboard_writes.clear(); self.pending_clipboard_reads.clear(); self.pending_notifications.clear(); + self.pending_agent_status = Some(None); self.pending_bell = false; self.kitty_keyboard.reset(); self.kitty_images.clear(); @@ -659,6 +660,12 @@ impl Handler for Terminal { } fn osc_dispatch(&mut self, data: &[u8]) { + if data.starts_with(b"777;noa-agent;") { + if let Some(status) = crate::osc::parse_agent_status_osc(data) { + self.pending_agent_status = Some(status); + } + return; + } if handle_color_osc(data, &mut self.colors, &mut self.pending_writes) { return; } diff --git a/crates/noa-grid/src/tests/osc.rs b/crates/noa-grid/src/tests/osc.rs index a4a19b70..a356840c 100644 --- a/crates/noa-grid/src/tests/osc.rs +++ b/crates/noa-grid/src/tests/osc.rs @@ -4,6 +4,31 @@ fn title_from_osc() { assert_eq!(t.title, "my title"); } +#[test] +fn explicit_agent_status_is_bounded_and_separate_from_notifications() { + let mut t = run(b"\x1b]777;noa-agent;permission;Edit\x1b\\"); + let report = t.take_pending_agent_status().unwrap().unwrap(); + assert_eq!(report.state, crate::AgentState::Permission); + assert_eq!(report.detail, "Edit"); + assert!(t.take_pending_notifications().is_empty()); + assert!(t.take_pending_writes().is_empty()); + assert!(t.take_pending_agent_status().is_none()); + + let mut invalid = run(b"\x1b]777;noa-agent;approved;anything\x07"); + assert!(invalid.take_pending_agent_status().is_none()); + let mut clear = run(b"\x1b]777;noa-agent;clear;\x07"); + assert_eq!(clear.take_pending_agent_status(), Some(None)); + let source = format!("\x1b]777;noa-agent;input;{}\x1b\\", "あ".repeat(300)); + let mut bounded = run(source.as_bytes()); + assert_eq!(bounded.take_pending_agent_status().unwrap().unwrap().detail.chars().count(), 160); +} + +#[test] +fn resetting_the_terminal_clears_reported_agent_state() { + let mut t = run(b"\x1b]777;noa-agent;permission;Edit\x1b\\\x1bc"); + assert_eq!(t.take_pending_agent_status(), Some(None)); +} + // tab-title REQ-TTL-5: a title set within a prompt cycle binds to the cwd // reported at that cycle's end, regardless of whether the shell's title hook // fires before or after its cwd hook. Both orders leave the fingerprint equal diff --git a/docs/AGENT_WORKFLOW.md b/docs/AGENT_WORKFLOW.md new file mode 100644 index 00000000..8282c096 --- /dev/null +++ b/docs/AGENT_WORKFLOW.md @@ -0,0 +1,204 @@ +# Coding agent workflows + +## Notifications + +Unread notifications belong to individual panes. Selecting a pane acknowledges +only that pane. An unselected pane can become unread even while its window is +focused. Desktop notifications retain their existing window-level suppression. + +Use **Cmd+Shift+J**, or **Go to Next Unread Notification** in the command palette, +to visit unread panes in the current window group. The configurable action is +`session.next-notification`. + +## Writing a prompt + +Open **Compose Prompt** (`agent.compose-prompt`) from the command palette. +The modeless native editor supports multiline text, Japanese IME, selection, +undo, and Find. Its title identifies the destination session, branch, and +directory. Selected terminal text becomes a quoted starting point when there +is no draft. + +**Paste to Pane** uses the existing bracketed paste/protection path and does +not send Enter. If the foreground process changed, reopen the composer to +review the destination. Confirm an active IME composition before pasting. + +Drafts stay in memory per pane, including across panel closes and pane moves. +They are removed on pane closure and are not saved across app restarts. +Prompts are limited to 1 MiB; oversized text must be shortened before pasting +or closing the editor. Optional shortcut: + +```conf +keybind = cmd+shift+e=agent.compose-prompt +``` + +## Reading output + +Open **Read Output Snapshot** (`terminal.read-output`). It shows the selection, +or the retained output tail when nothing is selected, in a read-only native +view. Snapshots are limited to 1 MiB. Live output cannot move the reader's +selection or scroll position. + +The view supports Find and copying, emphasizes Markdown headings, and uses +monospace for fenced code. **Copy Next Code Block** selects and copies each +fenced block in order, excluding fences. This is basic text/Markdown styling, +not a full HTML/Markdown renderer. + +New terminal output adds **New output available** to the title. **Return to +Latest** focuses the source pane at the live tail. Reopen the snapshot to read +updated output. + +## File navigation and preview + +```conf +file-link-editor = code +``` + +Supported values: `default`, `code`, `cursor`, `zed`, `subl`. The editor CLI +must be in Noa's inherited PATH. Cmd-click passes the absolute filename, line, +and column as one argument; `code`/`cursor` also receive `--goto`. No shell +evaluates the path. Directories and failed launches use the default macOS +handler. `default` preserves ordinary opening without line navigation. + +**Cmd+Option+click** previews a detected local path in a monospace panel. +Only regular UTF-8 files are previewed, up to 1 MiB; a worker reads the file. +Remote-pane paths remain excluded from local opening. + +## Explicit agent state + +Noa accepts this informational extension: + +```text +ESC ] 777;noa-agent;; ESC \ +``` + +| State | Meaning | +|---|---| +| `running` | Processing a prompt or tool operation | +| `permission` | An explicit permission request is outstanding | +| `input` | An explicit user-input request is outstanding | +| `finished` | The response ended; this does not assert task completion | +| `error` | The turn ended with an error | +| `clear` | Clear status; detail must be empty | + +Details are bounded to 160 characters with control characters removed. +Unknown states are ignored. Reports only update display state and never +authorize an operation or send input. Reading a notification does not resolve +a permission/input request. A `running` or `clear` report clears stale unread +state. Generic BEL/OSC notifications retain the neutral `notification` label. + +### Claude Code hooks + +The included `scripts/noa-agent-hook.py` writes lifecycle reports to the hook's +controlling terminal, leaving hook stdout untouched. It reports only event +kinds and tool names, and reads no transcripts. Agent settings are not changed +automatically. + +Merge the following with existing Claude Code hooks, replacing the example +script path with an absolute path to this checkout. Quote the script path +inside the command if it contains spaces. A detached hook without a controlling +terminal reports nothing. + +```json +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 /absolute/path/Noa/scripts/noa-agent-hook.py" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 /absolute/path/Noa/scripts/noa-agent-hook.py" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": ".*", + "hooks": [ + { + "type": "command", + "command": "python3 /absolute/path/Noa/scripts/noa-agent-hook.py" + } + ] + } + ], + "PermissionRequest": [ + { + "matcher": ".*", + "hooks": [ + { + "type": "command", + "command": "python3 /absolute/path/Noa/scripts/noa-agent-hook.py" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": ".*", + "hooks": [ + { + "type": "command", + "command": "python3 /absolute/path/Noa/scripts/noa-agent-hook.py" + } + ] + } + ], + "Notification": [ + { + "matcher": "permission_prompt|idle_prompt|elicitation_dialog", + "hooks": [ + { + "type": "command", + "command": "python3 /absolute/path/Noa/scripts/noa-agent-hook.py" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 /absolute/path/Noa/scripts/noa-agent-hook.py" + } + ] + } + ], + "StopFailure": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 /absolute/path/Noa/scripts/noa-agent-hook.py" + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 /absolute/path/Noa/scripts/noa-agent-hook.py" + } + ] + } + ] + } +} +``` + +Event meanings follow the [Claude Code hook reference](https://code.claude.com/docs/en/hooks). +Other agents can emit the protocol; only a Claude Code adapter is included. +Structured status currently feeds local cards; remote client metadata is unchanged. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 7ef0a7b1..9f59d4fe 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -150,6 +150,7 @@ or fails to decode, a diagnostic is shown and the background image is disabled. | `scrollback-limit` | integer `>= 0` | `10000000` | Total byte count for scrollback. `0` disables it | | `clipboard-read` | `deny` / `false`, `ask`, `allow` / `true` | `ask` | Policy for OSC 52 clipboard read | | `clipboard-paste-protection` | `true`, `false` | `true` | Confirmation for pastes that could trigger command execution | +| `file-link-editor` | `default`, `code`, `cursor`, `zed`, `subl` | `default` | Editor CLI for Cmd-clicked local paths with line/column navigation. Must be in Noa's inherited PATH. Directories and launch failures use the default macOS handler. See [Agent workflows](AGENT_WORKFLOW.md) | | `title-report` | `true`, `false` | `false` | Allow window title responses via `CSI 21 t` | | `visual-bell` | `true`, `false` | `false` | Flash the window on BEL | | `audible-bell` | `true`, `false` | `false` | Play a platform sound on BEL | diff --git a/docs/FEATURES.md b/docs/FEATURES.md index b528d0d9..458e79a0 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -45,6 +45,8 @@ A from-scratch DFA parser plus a `Handler` trait separating parsing from state. - **Theme & settings overlay** — a theme/settings editor with live preview opened from `Settings…` (⌘,), writes back to config - **Sidebar (session list)** — per-window session cards, process badges, inline rename, OSC 9;4 determinate/indeterminate progress - **Agent attention** — agent-process classification, bell-to-attention escalation, categorical status rails, one-shot arrival emphasis, Dock attention +- **Agent workflows** — pane-scoped notifications, next-unread navigation (⌘⇧J), explicit agent state and a Claude Code hook adapter, native multiline prompt drafts, and read-only output snapshots with search/code copying; see [Agent workflows](AGENT_WORKFLOW.md) +- **Local file navigation** — configurable editor line/column navigation on ⌘-click; UTF-8 file preview on ⌘⌥-click - **About panel** — version + git hash + build date, bundled-icon resolution - **Confirmation dialogs** — paste protection / OSC 52 / close confirmation - **IME preedit** — underlined display of in-progress composition text diff --git a/docs/KEYBINDINGS.md b/docs/KEYBINDINGS.md index 1839f26c..4277331f 100644 --- a/docs/KEYBINDINGS.md +++ b/docs/KEYBINDINGS.md @@ -12,6 +12,12 @@ noa +list-keybinds ## Config `keybind =` +Agent workflow additions: **⌘⇧J** opens the next unread notification +(`session.next-notification`). **Compose Prompt** (`agent.compose-prompt`) and +**Read Output Snapshot** (`terminal.read-output`) are available from the command +palette and can be assigned custom keys. **⌘⌥-click** previews local text files; +**⌘-click** opens them in the configured editor. See [Agent workflows](AGENT_WORKFLOW.md). + `keybind = =` adds to or overrides the default table. The same chord takes the later entry. `keybind = =unbind` clears that chord, and `keybind = clear` clears all bindings defined before it. @@ -48,6 +54,7 @@ names — such as `new_tab`, `prompt_surface_title`, | Window | `window.new`, `window.close`, `fullscreen.toggle` | | Split | `split.new-left`, `split.new-right`, `split.new-up`, `split.new-down`, `split.focus-left`, `split.focus-right`, `split.focus-up`, `split.focus-down`, `split.resize-left`, `split.resize-right`, `split.resize-up`, `split.resize-down`, `split.equalize`, `split.toggle-zoom` | | UI | `session-overview.toggle`, `command-palette.toggle`, `quick-terminal.toggle`, `secure-keyboard-entry.toggle`, `sidebar.toggle`, `auto-approve.toggle`, `theme-settings.open` | +| Agent workflows | `session.next-notification`, `agent.compose-prompt`, `terminal.read-output` | `tab-overview.toggle` is also accepted as a compatible name for `session-overview.toggle`. If the input contains `_`, the name with `-` diff --git a/docs/specs/agent-attention.md b/docs/specs/agent-attention.md index 7f81627b..0305e7ba 100644 --- a/docs/specs/agent-attention.md +++ b/docs/specs/agent-attention.md @@ -1,5 +1,9 @@ # Agent Attention Notification — Specification +Amended 2026-09-05 by [Agent workflow improvements](agent-workflow.md): unread +state is now acknowledged per pane. Explicit agent reports can name a waiting +reason; generic BEL/OSC 9/777 notifications retain neutral wording. + ## Metadata - slug: `agent-attention` @@ -15,20 +19,20 @@ When several Claude Code / Codex / agy sessions run concurrently, Noa should make a newly raised notification easy to notice without leaving a distracting animation running. OSC 9/777 indicates that a notification exists; it does not prove that the process is blocked awaiting a response. The UI therefore uses -the neutral label `notification` and preserves it until the relevant window -gains focus. +the neutral label `notification` and preserves it until the relevant pane +is selected in the OS-focused window. - **audience**: developers running multiple concurrent terminal sessions - **job-to-be-done**: identify which session changed state at a glance - **success**: the new state gets a brief one-shot emphasis, then remains - identifiable through a stable shape, color, and label; focus clears it + identifiable through a stable shape, color, and label; selecting its pane clears it ### Existing foundation - `SessionCard { unread_bell, attention, busy, process, … }` - `StatusDot { Blue, Green, Yellow, Red }` with priority **attention > bell > busy > idle** -- `SessionDelta::{ Bell, Attention }`; focus clears both flags +- `SessionDelta::{ Bell, Attention }`; selecting the pane clears both flags - OSC 9/777 posts an OS notification and requests Dock attention - BEL from a known agent process is promoted to attention; generic BEL remains an unread bell @@ -64,8 +68,10 @@ gains focus. - **FR-A5 Dock/OS notification**: an unfocused transition to attention requests Dock attention once. OSC 9/777 also posts to Notification Center; BEL-promoted attention does not, avoiding notification overload. -- **FR-A6 Clearing**: focusing the relevant window immediately clears attention, - unread BEL, and any unfinished one-shot emphasis. +- **FR-A6 Clearing**: selecting the relevant pane in the OS-focused window clears + its unread attention/BEL and one-shot emphasis. Sibling panes retain theirs. + A window focus gain acknowledges only its selected pane. Explicit waiting + status remains until the reporting agent changes it. - **FR-A7 Repeated firing**: another attention delta while attention is already pending does not restart the emphasis. A new occurrence after focus cleared the state starts a fresh emphasis. @@ -87,8 +93,8 @@ gains focus. rail precedence, and the three rail geometries. - **AC-A2 (FR-A3/NFR-A4)**: unit tests verify known-agent BEL promotion and generic/unresolved fallback. -- **AC-A3 (FR-A6)**: store/unit verification confirms window focus clears - attention and unread BEL; manual verification confirms the visual clears. +- **AC-A3 (FR-A6)**: store/unit verification confirms acknowledgement clears only + the selected pane, retaining sibling and other-window notifications. - **AC-A4 (FR-A7)**: repeated attention while pending leaves the existing emphasis deadline unchanged. - **AC-A5 (FR-A2) [manual]**: a new notification briefly emphasizes the sidebar diff --git a/docs/specs/agent-workflow.md b/docs/specs/agent-workflow.md new file mode 100644 index 00000000..b8484e9a --- /dev/null +++ b/docs/specs/agent-workflow.md @@ -0,0 +1,53 @@ +# Agent workflow improvements + +Status: implemented (2026-09-05). + +Scope: pane-scoped unread notifications and next-unread navigation; local +file links with editor line/column navigation; explicit agent status reports; +multiline prompt drafts and a readable output view. Project grouping and a +Git/test results panel remain outside this iteration unless requested. + +Acceptance: + +1. Selecting one pane acknowledges only that pane. A sibling pane in the + focused window can still become unread. Next Notification visits unread + panes in the current window group. +2. A configured editor receives the absolute filename, line, and column as + arguments without shell evaluation. Missing editors fall back to the + default file handler; directories use the default handler. +3. Explicit status reports distinguish running, permission/input waiting, + response end, and error. Generic BEL/OSC notifications remain neutral. + Acknowledging a notification does not resolve an outstanding agent request. +4. Prompt composition supports native multiline editing/IME and per-pane + drafts, with an explicit destination and paste action. +5. A read-only output snapshot supports selection, copying, and search while + the terminal continues receiving output. + +Usage and integration setup: [Coding agent workflows](../AGENT_WORKFLOW.md). + +Implemented: + +- Pane acknowledgement and next-unread navigation (Cmd+Shift+J), including + revealing the destination when another pane is zoomed. +- Configured editor line/column navigation and asynchronous local UTF-8 previews. +- Explicit agent states and a Claude Code lifecycle hook adapter. +- Native prompt drafts, read-only output snapshots, Find, and fenced code copying. + Draft paste follows the existing protection path and never sends Enter. + +Verification: + +- `cargo test --workspace`: passed with local IPC sockets permitted. +- `cargo test -p noa-app --lib --quiet`: final app changes passed; + 1,178 tests passed, 6 ignored. +- `cargo build --workspace`: passed. +- `cargo fmt --all -- --check` and `git diff --check`: passed. +- `python3 -B -m unittest discover -s scripts -p test_noa_agent_hook.py`: + 4 tests passed. +- `bash scripts/test-native-text-panels.sh`: passed on macOS. Synthetic panels + verify Japanese draft preservation, reader mode, native Find/selection routing, + and clean closure without launching a shell or modifying the clipboard. + +Verification limits: interactive Japanese IME candidate selection, installed +editor CLI launches, and an actual Claude Code session were not exercised. +Editor arguments and hook payloads are covered by deterministic tests. Only +the Claude Code adapter is included; remote metadata has no structured status. diff --git a/scripts/noa-agent-hook.py b/scripts/noa-agent-hook.py new file mode 100644 index 00000000..7c05342c --- /dev/null +++ b/scripts/noa-agent-hook.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Report Claude Code lifecycle events to the owning Noa terminal.""" + +import json +import os +import sys +import unicodedata + + +def report(event): + if not isinstance(event, dict): + return None + name = event.get("hook_event_name") + state = { + "SessionStart": "clear", + "SessionEnd": "clear", + "UserPromptSubmit": "running", + "PreToolUse": "running", + "PostToolUse": "running", + "PermissionRequest": "permission", + "Stop": "finished", + "StopFailure": "error", + }.get(name) + if name == "Notification": + state = { + "permission_prompt": "permission", + "idle_prompt": "input", + "elicitation_dialog": "input", + }.get(event.get("notification_type")) + if state is None: + return None + # Tool names identify the operation without exposing prompts, arguments, + # file contents, transcripts, or credentials in the sidebar. + detail = event.get("tool_name", "") if name in { + "PreToolUse", "PostToolUse", "PermissionRequest" + } else "" + if not isinstance(detail, str): + detail = "" + detail = "".join(c for c in detail if not unicodedata.category(c).startswith("C"))[:160] + return f"\033]777;noa-agent;{state};{detail}\033\\".encode("utf-8") + + +def main(): + try: + data = sys.stdin.buffer.read(1024 * 1024 + 1) + if len(data) > 1024 * 1024: + return + payload = report(json.loads(data)) + if payload is None: + return + # Hook stdout belongs to the agent's hook protocol, not the terminal. + # A detached hook without a controlling tty simply has nothing to report. + fd = os.open("/dev/tty", os.O_WRONLY | os.O_NOCTTY) + try: + os.write(fd, payload) + finally: + os.close(fd) + except (OSError, ValueError, TypeError): + return + + +if __name__ == "__main__": + main() diff --git a/scripts/test-native-text-panels.sh b/scripts/test-native-text-panels.sh new file mode 100644 index 00000000..42981ccd --- /dev/null +++ b/scripts/test-native-text-panels.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Package synthetic GUI checks so LaunchServices can activate their native windows. +set -euo pipefail + +repo_dir="$(cd "$(dirname "$0")/.." && pwd)" +cd "$repo_dir" +cargo build -p noa-app --example native-text-panels +smoke_dir="$(mktemp -d "${TMPDIR:-/tmp}/noa-text-panels.XXXXXX")" +trap 'rm -rf "$smoke_dir"' EXIT +smoke_app="$smoke_dir/NoaPanelSmoke.app" +mkdir -p "$smoke_app/Contents/MacOS" +cp target/debug/examples/native-text-panels "$smoke_app/Contents/MacOS/NoaPanelSmoke" +cat > "$smoke_app/Contents/Info.plist" <<'PLIST' + + + +CFBundleNameNoaPanelSmoke +CFBundleIdentifierorg.noa.panel-smoke +CFBundleExecutableNoaPanelSmoke +CFBundlePackageTypeAPPL +NSHighResolutionCapable + +PLIST +open -W -n "$smoke_app" --stdout "$smoke_dir/stdout" --stderr "$smoke_dir/stderr" +cat "$smoke_dir/stdout" "$smoke_dir/stderr" +# open reports successful launching even when the child process failed an assertion. +grep -Fq 'Native composer, Japanese draft, reader, find routing, and close checks passed.' "$smoke_dir/stdout" diff --git a/scripts/test_noa_agent_hook.py b/scripts/test_noa_agent_hook.py new file mode 100644 index 00000000..5c52a189 --- /dev/null +++ b/scripts/test_noa_agent_hook.py @@ -0,0 +1,29 @@ +import importlib.util +from pathlib import Path +import unittest + +spec = importlib.util.spec_from_file_location("noa_agent_hook", Path(__file__).with_name("noa-agent-hook.py")) +hook = importlib.util.module_from_spec(spec) +spec.loader.exec_module(hook) + + +class AgentHookTests(unittest.TestCase): + def test_lifecycle_does_not_claim_task_completion(self): + self.assertEqual(hook.report({"hook_event_name": "Stop"}), b"\x1b]777;noa-agent;finished;\x1b\\") + self.assertEqual(hook.report({"hook_event_name": "SessionEnd"}), b"\x1b]777;noa-agent;clear;\x1b\\") + + def test_permission_is_explicit_and_does_not_leak_arguments(self): + payload = hook.report({"hook_event_name": "PermissionRequest", "tool_name": "Bash", "tool_input": {"command": "private contents"}}) + self.assertEqual(payload, b"\x1b]777;noa-agent;permission;Bash\x1b\\") + + def test_unrelated_notifications_are_not_interpreted_as_waiting(self): + self.assertIsNone(hook.report({"hook_event_name": "Notification", "notification_type": "other"})) + self.assertIsNone(hook.report([])) + + def test_tool_names_cannot_inject_terminal_commands(self): + payload = hook.report({"hook_event_name": "PreToolUse", "tool_name": "X\x1b\x07\nY"}) + self.assertEqual(payload, b"\x1b]777;noa-agent;running;XY\x1b\\") + + +if __name__ == "__main__": + unittest.main() From 3d4309128f4e2aa5b33dd3c600b3d32cf60a07cc Mon Sep 17 00:00:00 2001 From: "shingo.imota" Date: Sat, 5 Sep 2026 11:34:57 +0900 Subject: [PATCH 2/4] feat(settings): expose agent workflow options Let users choose the file-link editor and open the bundled workflow guide from Settings. Apply editor changes on save and preserve cancel, reset, and undo behavior. --- crates/noa-app/examples/native-text-panels.rs | 41 ++++-- .../noa-app/src/app/input_ops/text_panel.rs | 34 +++++ .../src/app/input_ops/theme_settings.rs | 13 +- crates/noa-app/src/app/state.rs | 1 + crates/noa-app/src/macos_overlay/tests.rs | 34 +++++ crates/noa-app/src/text_panel.rs | 8 +- crates/noa-app/src/theme_settings/rows.rs | 31 ++++- crates/noa-app/src/theme_settings/state.rs | 45 +++++++ crates/noa-app/src/theme_settings/tests.rs | 124 +++++++++++++++++- docs/AGENT_WORKFLOW.md | 15 +++ docs/specs/agent-workflow.md | 7 +- scripts/test-native-text-panels.sh | 2 +- 12 files changed, 333 insertions(+), 22 deletions(-) diff --git a/crates/noa-app/examples/native-text-panels.rs b/crates/noa-app/examples/native-text-panels.rs index c134f87a..58397016 100644 --- a/crates/noa-app/examples/native-text-panels.rs +++ b/crates/noa-app/examples/native-text-panels.rs @@ -98,7 +98,7 @@ fn main() { } fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {} fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { - if self.phase == 4 { + if self.phase == 6 { return; } if Instant::now() < self.ready { @@ -106,17 +106,17 @@ fn main() { return; } let panel = self.panel.as_ref().unwrap(); - if self.phase == 0 || self.phase == 2 { + 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(if self.phase == 0 { - "Compose Prompt" - } else { - "Output Snapshot" + 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)); @@ -143,8 +143,27 @@ fn main() { assert!(panel.draft().is_none()); panel.note_output(split_tree::PaneId::new(1)); panel.close(); - self.phase = 4; - event_loop.exit(); + 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(); + } } } } @@ -160,8 +179,10 @@ fn main() { focus_deadline: Instant::now(), }; event_loop.run_app(&mut smoke).unwrap(); - assert_eq!(smoke.phase, 4); - println!("Native composer, Japanese draft, reader, find routing, and close checks passed."); + assert_eq!(smoke.phase, 6); + println!( + "Native composer, Japanese draft, reader, guide, find routing, and close checks passed." + ); } #[cfg(not(target_os = "macos"))] diff --git a/crates/noa-app/src/app/input_ops/text_panel.rs b/crates/noa-app/src/app/input_ops/text_panel.rs index 1b3b45a9..e0996a97 100644 --- a/crates/noa-app/src/app/input_ops/text_panel.rs +++ b/crates/noa-app/src/app/input_ops/text_panel.rs @@ -1,6 +1,40 @@ use super::super::*; impl App { + pub(in crate::app) fn open_agent_workflow_guide(&mut self) { + #[cfg(target_os = "macos")] + { + let Some((window_id, pane_id)) = self.theme_settings.as_ref().and_then(|session| { + self.windows + .get(&session.window_id) + .map(|state| (session.window_id, state.focused_pane)) + }) else { + return; + }; + if let Some(panel) = &self.text_panel { + if !panel.can_close() { + return; + } + if let Some((pane, text)) = panel.draft() { + self.prompt_drafts.insert(pane, text); + } + } + // Embed the guide so packaged apps work without a repository checkout. + match crate::text_panel::TextPanel::open( + "Agent Workflows", + include_str!("../../../../../docs/AGENT_WORKFLOW.md"), + crate::text_panel::TextPanelMode::Guide, + window_id, + pane_id, + None, + self.proxy.clone(), + ) { + Ok(panel) => self.text_panel = Some(panel), + Err(err) => log::warn!("could not open agent workflow guide: {err}"), + } + } + } + pub(in crate::app) fn open_file_preview( &self, window_id: WindowId, diff --git a/crates/noa-app/src/app/input_ops/theme_settings.rs b/crates/noa-app/src/app/input_ops/theme_settings.rs index 5cf033f7..1c60d6f3 100644 --- a/crates/noa-app/src/app/input_ops/theme_settings.rs +++ b/crates/noa-app/src/app/input_ops/theme_settings.rs @@ -229,6 +229,7 @@ impl App { glassmorphism: self.config.glassmorphism, confirm_quit: self.config.confirm_quit, send_selection_send_enter: self.config.send_selection_send_enter, + file_link_editor: self.config.file_link_editor, font_family, available_font_families, scrollback_limit: self.config.scrollback_limit, @@ -596,6 +597,7 @@ impl App { RowEffect::SidebarFontSize(size) => self.apply_live_sidebar_font_size(size), RowEffect::CopyServerToken => self.copy_server_token_to_clipboard(), RowEffect::ShowRemoteAppQr => self.show_remote_app_qr(), + RowEffect::OpenAgentWorkflowGuide => self.open_agent_workflow_guide(), } } @@ -1034,6 +1036,7 @@ impl App { } } self.config.font_size = payload.revert.font_size; + self.config.file_link_editor = payload.revert.file_link_editor; self.config.background_opacity = payload.revert.background_opacity; self.config.background_blur_radius = payload.revert.background_blur_radius; // The `configured_*` twins too, symmetrically with the commit path's @@ -1192,6 +1195,9 @@ impl App { RowDraft::SendSelectionSendEnter(v) => { self.config.send_selection_send_enter = *v; } + RowDraft::FileLinkEditor(editor) => { + self.config.file_link_editor = *editor; + } // Commit-only rows: intentionally not mirrored (see the doc // comment above). RowDraft::FontFamily(_) @@ -1235,7 +1241,9 @@ impl App { // explicit rather than folded into the arm above so a // future variant can't silently start skipping a real // config mirror. - RowDraft::ServerTokenCopy(_) | RowDraft::ServerStatus(_) => {} + RowDraft::ServerTokenCopy(_) + | RowDraft::ServerStatus(_) + | RowDraft::AgentWorkflowGuide => {} } } if reload_background_image { @@ -1757,6 +1765,7 @@ mod commit_theme_settings_tests { glassmorphism: noa_config::GlassLevel::Off, confirm_quit: true, send_selection_send_enter: false, + file_link_editor: noa_config::FileLinkEditor::Default, font_family: "Menlo".to_string(), available_font_families: Vec::new(), scrollback_limit: noa_config::DEFAULT_SCROLLBACK_LIMIT, @@ -1843,6 +1852,7 @@ mod commit_theme_settings_tests { glassmorphism: noa_config::GlassLevel::Off, confirm_quit: true, send_selection_send_enter: false, + file_link_editor: noa_config::FileLinkEditor::Default, font_family: "Menlo".to_string(), }; sync_reverted_confirm_quit_and_quick_terminal_size(&mut config, &revert); @@ -1910,6 +1920,7 @@ mod commit_theme_settings_tests { glassmorphism: noa_config::GlassLevel::Off, confirm_quit: true, send_selection_send_enter: false, + file_link_editor: noa_config::FileLinkEditor::Default, font_family: "Menlo".to_string(), available_font_families: Vec::new(), scrollback_limit: noa_config::DEFAULT_SCROLLBACK_LIMIT, diff --git a/crates/noa-app/src/app/state.rs b/crates/noa-app/src/app/state.rs index 2d4d6c2f..06c53caf 100644 --- a/crates/noa-app/src/app/state.rs +++ b/crates/noa-app/src/app/state.rs @@ -789,6 +789,7 @@ mod theme_settings_session_tests { glassmorphism: noa_config::GlassLevel::Off, confirm_quit: true, send_selection_send_enter: false, + file_link_editor: noa_config::FileLinkEditor::Default, font_family: "Menlo".to_string(), available_font_families: Vec::new(), scrollback_limit: noa_config::DEFAULT_SCROLLBACK_LIMIT, diff --git a/crates/noa-app/src/macos_overlay/tests.rs b/crates/noa-app/src/macos_overlay/tests.rs index 6a0de6ea..ab6e80ac 100644 --- a/crates/noa-app/src/macos_overlay/tests.rs +++ b/crates/noa-app/src/macos_overlay/tests.rs @@ -35,6 +35,7 @@ fn settings_init() -> ThemeSettingsInit { glassmorphism: noa_config::GlassLevel::Off, confirm_quit: true, send_selection_send_enter: false, + file_link_editor: noa_config::FileLinkEditor::Default, font_family: "Menlo".to_string(), available_font_families: Vec::new(), scrollback_limit: noa_config::DEFAULT_SCROLLBACK_LIMIT, @@ -154,6 +155,38 @@ fn view_model_settings_visible_reflects_the_active_search_filter() { assert_eq!(vm.search_query, "cursor style"); } +#[test] +fn settings_search_exposes_editor_choice_and_agent_guide() { + for (query, label, value, liveness) in [ + ( + "file link editor", + "File Link Editor", + "Cursor", + Liveness::OnSave, + ), + ( + "agent workflows", + "Agent Workflows", + "Open Guide", + Liveness::Live, + ), + ] { + let mut state = ThemeSettings::open(ThemeSettingsInit { + file_link_editor: noa_config::FileLinkEditor::Cursor, + ..settings_init() + }); + state.toggle_settings_search(); + state.push_text(query, std::time::Instant::now()); + let model = theme_settings_view_model(&state); + assert_eq!(model.settings_visible.len(), 1); + let row = &model.rows[model.settings_visible[0]]; + assert_eq!(row.label, label); + assert_eq!(row.value, value); + assert_eq!(row.liveness, liveness); + assert!(row.selected); + } +} + fn test_colors() -> OverlayColors { OverlayColors { surface_fg: [1.0, 1.0, 1.0, 1.0], @@ -202,6 +235,7 @@ fn test_theme_settings_init() -> ThemeSettingsInit { glassmorphism: noa_config::GlassLevel::Off, confirm_quit: true, send_selection_send_enter: false, + file_link_editor: noa_config::FileLinkEditor::Default, font_family: "Menlo".to_string(), available_font_families: Vec::new(), scrollback_limit: noa_config::DEFAULT_SCROLLBACK_LIMIT, diff --git a/crates/noa-app/src/text_panel.rs b/crates/noa-app/src/text_panel.rs index 7b51c08f..34a43214 100644 --- a/crates/noa-app/src/text_panel.rs +++ b/crates/noa-app/src/text_panel.rs @@ -7,6 +7,7 @@ pub(crate) enum TextPanelMode { Compose, Output, File, + Guide, } pub(crate) fn bounded_text(text: &str) -> String { @@ -251,7 +252,10 @@ mod native { } else { super::code_blocks(&text) }; - if mode == super::TextPanelMode::Output { + if matches!( + mode, + super::TextPanelMode::Output | super::TextPanelMode::Guide + ) { let prose: *mut AnyObject = msg_send![font_class, systemFontOfSize: 15.0_f64]; let _: () = msg_send![&*text_view, setFont: prose]; for block in &blocks { @@ -296,7 +300,7 @@ mod native { let _: () = msg_send![&*find, setAction: sel!(performFindPanelAction:)]; let _: () = msg_send![content, addSubview: &*find]; - if !editable { + if !editable && mode != super::TextPanelMode::Guide { let allocated: *mut AnyObject = msg_send![button_class, alloc]; let latest = owned(msg_send![allocated, initWithFrame: rect(125.0, 14.0, 160.0, 30.0)])?; diff --git a/crates/noa-app/src/theme_settings/rows.rs b/crates/noa-app/src/theme_settings/rows.rs index a08fe486..a94b8f76 100644 --- a/crates/noa-app/src/theme_settings/rows.rs +++ b/crates/noa-app/src/theme_settings/rows.rs @@ -177,10 +177,12 @@ pub(crate) enum SettingsRowKind { /// would be worse than no row at all — this one exists so a user can see /// whether their terminal is recording. ScrollbackPersist, + FileLinkEditor, + AgentWorkflowGuide, } impl SettingsRowKind { - pub(crate) const COUNT: usize = 34; + pub(crate) const COUNT: usize = 36; pub(crate) const ALL: [SettingsRowKind; Self::COUNT] = [ Self::FontSize, Self::BackgroundOpacity, @@ -216,6 +218,8 @@ impl SettingsRowKind { Self::ScratchTerminalKey, Self::ScratchTerminalSize, Self::ScrollbackPersist, + Self::FileLinkEditor, + Self::AgentWorkflowGuide, ]; /// R-8: the fixed live/commit-only classification, one row's kind at a @@ -237,6 +241,7 @@ impl SettingsRowKind { | Self::ServerTokenCopy | Self::ServerRemoteAppQr | Self::ServerStatus + | Self::AgentWorkflowGuide ) } @@ -281,6 +286,8 @@ impl SettingsRowKind { Self::ScratchTerminalKey => "Scratch Terminal Key", Self::ScratchTerminalSize => "Scratch Terminal Size", Self::ScrollbackPersist => "Persist Scrollback", + Self::FileLinkEditor => "File Link Editor", + Self::AgentWorkflowGuide => "Agent Workflows", } } @@ -360,6 +367,12 @@ impl SettingsRowKind { Self::ScrollbackPersist => { "Keep each pane's scrollback tail on disk across restarts — writes terminal output to disk, so it's opt-in. Applies on save." } + Self::FileLinkEditor => { + "Editor for Cmd-clicked paths; CLI must be in PATH. Applies on save." + } + Self::AgentWorkflowGuide => { + "Prompt drafts, output reader, unread shortcuts, and agent hook setup." + } } } } @@ -496,6 +509,8 @@ pub(crate) enum RowDraft { ScratchTerminalSize(u16, u16), /// `scrollback-persist` for [`SettingsRowKind::ScrollbackPersist`]. ScrollbackPersist(ScrollbackPersist), + FileLinkEditor(noa_config::FileLinkEditor), + AgentWorkflowGuide, } /// [`RowDraft::ServerTokenCopy`]'s three faces — deliberately holds no @@ -609,6 +624,15 @@ impl RowDraft { ScrollbackPersist::Never => "Off".to_string(), ScrollbackPersist::Tail => "Record Tail".to_string(), }, + RowDraft::FileLinkEditor(editor) => match editor { + noa_config::FileLinkEditor::Default => "System Default", + noa_config::FileLinkEditor::Code => "VS Code", + noa_config::FileLinkEditor::Cursor => "Cursor", + noa_config::FileLinkEditor::Zed => "Zed", + noa_config::FileLinkEditor::Subl => "Sublime Text", + } + .to_string(), + RowDraft::AgentWorkflowGuide => "Open Guide".to_string(), } } @@ -707,6 +731,8 @@ impl RowDraft { d.scratch_terminal_size.rows, ), SettingsRowKind::ScrollbackPersist => RowDraft::ScrollbackPersist(d.scrollback_persist), + SettingsRowKind::FileLinkEditor => RowDraft::FileLinkEditor(d.file_link_editor), + SettingsRowKind::AgentWorkflowGuide => RowDraft::AgentWorkflowGuide, } } } @@ -755,6 +781,7 @@ pub(crate) struct SettingsRow { /// it always routes through the debouncer (`poll_font_size`), per R-9. #[derive(Debug, Clone, Copy, PartialEq)] pub(crate) enum RowEffect { + OpenAgentWorkflowGuide, /// Nothing to apply outside the state machine (commit-only rows, or a /// live row whose value didn't actually change). None, @@ -820,6 +847,7 @@ pub(crate) struct RevertValues { pub(crate) glassmorphism: GlassLevel, pub(crate) confirm_quit: bool, pub(crate) send_selection_send_enter: bool, + pub(crate) file_link_editor: noa_config::FileLinkEditor, pub(crate) font_family: String, } @@ -933,6 +961,7 @@ pub(crate) struct ThemeSettingsInit { pub(crate) glassmorphism: GlassLevel, pub(crate) confirm_quit: bool, pub(crate) send_selection_send_enter: bool, + pub(crate) file_link_editor: noa_config::FileLinkEditor, pub(crate) font_family: String, pub(crate) available_font_families: Vec, /// R-9. diff --git a/crates/noa-app/src/theme_settings/state.rs b/crates/noa-app/src/theme_settings/state.rs index 34589db3..cceb496a 100644 --- a/crates/noa-app/src/theme_settings/state.rs +++ b/crates/noa-app/src/theme_settings/state.rs @@ -321,6 +321,7 @@ impl ThemeSettings { glassmorphism: init.glassmorphism, confirm_quit: init.confirm_quit, send_selection_send_enter: init.send_selection_send_enter, + file_link_editor: init.file_link_editor, font_family: init.font_family.clone(), }, [ @@ -468,6 +469,14 @@ impl ThemeSettings { draft: RowDraft::ScrollbackPersist(init.scrollback_persist), touched: false, }, + SettingsRow { + draft: RowDraft::FileLinkEditor(init.file_link_editor), + touched: false, + }, + SettingsRow { + draft: RowDraft::AgentWorkflowGuide, + touched: false, + }, ], !init.window_created_transparent, // A fresh session has nothing to restore: `glassmorphism` @@ -1579,6 +1588,30 @@ impl ThemeSettings { RowEffect::None } SettingsRowKind::ServerRemoteAppQr => RowEffect::ShowRemoteAppQr, + SettingsRowKind::AgentWorkflowGuide => RowEffect::OpenAgentWorkflowGuide, + SettingsRowKind::FileLinkEditor => { + use noa_config::FileLinkEditor; + const EDITORS: [FileLinkEditor; 5] = [ + FileLinkEditor::Default, + FileLinkEditor::Code, + FileLinkEditor::Cursor, + FileLinkEditor::Zed, + FileLinkEditor::Subl, + ]; + let RowDraft::FileLinkEditor(current) = self.rows[idx].draft else { + return RowEffect::None; + }; + let index = EDITORS + .iter() + .position(|editor| *editor == current) + .unwrap(); + let next = (index as i32 + delta).rem_euclid(EDITORS.len() as i32) as usize; + if EDITORS[next] != current { + self.rows[idx].draft = RowDraft::FileLinkEditor(EDITORS[next]); + self.rows[idx].touched = true; + } + RowEffect::None + } // Action row (R-2's exception, see `SettingsRowKind::ServerTokenCopy`'s // doc comment): never sets `touched` and never rewrites its own // draft here — `App` performs the actual clipboard write and @@ -1772,6 +1805,7 @@ impl ThemeSettings { SettingsRowKind::ServerTokenCopy | SettingsRowKind::ServerRemoteAppQr | SettingsRowKind::ServerStatus + | SettingsRowKind::AgentWorkflowGuide ) { return RowEffect::None; } @@ -2261,6 +2295,10 @@ impl ThemeSettings { // `RowDraft` variant can't silently skip a real config write // by landing here instead. RowDraft::ServerTokenCopy(_) => {} + RowDraft::AgentWorkflowGuide => {} + RowDraft::FileLinkEditor(editor) => { + updates.push(("file-link-editor".to_string(), editor.as_str().to_string())); + } // Same "never touched" contract as `ServerTokenCopy` above. RowDraft::ServerStatus(_) => {} // Mirrors `FontFamily`'s empty-default skip: an invalid, @@ -2515,6 +2553,10 @@ pub(crate) fn revert_updates( revert.glassmorphism.to_string(), )); updates.push(("confirm-quit".to_string(), revert.confirm_quit.to_string())); + updates.push(( + "file-link-editor".to_string(), + revert.file_link_editor.as_str().to_string(), + )); updates.push(( "send-selection-send-enter".to_string(), revert.send_selection_send_enter.to_string(), @@ -2568,6 +2610,7 @@ fn is_reload_exempt(row: SettingsRowKind) -> bool { | SettingsRowKind::BackgroundImageInterval | SettingsRowKind::Glassmorphism | SettingsRowKind::ConfirmQuit + | SettingsRowKind::FileLinkEditor | SettingsRowKind::SendSelectionSendEnter | SettingsRowKind::QuickTerminalHeight // R-9/Addendum D-1's FM-01 correction: these three are picked up @@ -2704,6 +2747,8 @@ fn hash_row_draft_value(draft: &RowDraft, hasher: &mut impl Hasher) { rows.hash(hasher); } RowDraft::ScrollbackPersist(mode) => scrollback_persist_config_value(*mode).hash(hasher), + RowDraft::FileLinkEditor(editor) => editor.as_str().hash(hasher), + RowDraft::AgentWorkflowGuide => {} } } diff --git a/crates/noa-app/src/theme_settings/tests.rs b/crates/noa-app/src/theme_settings/tests.rs index 3eea4695..ff99d91d 100644 --- a/crates/noa-app/src/theme_settings/tests.rs +++ b/crates/noa-app/src/theme_settings/tests.rs @@ -43,6 +43,7 @@ fn init() -> ThemeSettingsInit { glassmorphism: GlassLevel::Off, confirm_quit: true, send_selection_send_enter: false, + file_link_editor: noa_config::FileLinkEditor::Default, font_family: "Menlo".to_string(), available_font_families: vec![ "Menlo".to_string(), @@ -90,6 +91,114 @@ fn assert_quick_terminal_height(draft: &RowDraft, expected: f32) { assert!((*actual - expected).abs() < 0.001, "got {actual}"); } +#[test] +fn file_link_editor_cycles_in_both_directions_and_applies_on_save() { + use noa_config::FileLinkEditor; + let mut settings = ThemeSettings::open(settings_init()); + move_to_row(&mut settings, SettingsRowKind::FileLinkEditor); + assert!(settings.commit_updates().is_empty()); + assert_eq!( + settings.liveness(SettingsRowKind::FileLinkEditor), + Liveness::OnSave + ); + let index = row_index(SettingsRowKind::FileLinkEditor); + for (editor, label) in [ + (FileLinkEditor::Code, "VS Code"), + (FileLinkEditor::Cursor, "Cursor"), + (FileLinkEditor::Zed, "Zed"), + (FileLinkEditor::Subl, "Sublime Text"), + (FileLinkEditor::Default, "System Default"), + ] { + assert_eq!(settings.adjust(1, Instant::now()), RowEffect::None); + assert_eq!( + settings.rows()[index].draft, + RowDraft::FileLinkEditor(editor) + ); + assert_eq!(settings.rows()[index].draft.display_value(), label); + assert_eq!( + settings.restart_reason(SettingsRowKind::FileLinkEditor), + RestartReason::None + ); + } + settings.adjust(-1, Instant::now()); + assert_eq!( + settings.rows()[index].draft, + RowDraft::FileLinkEditor(FileLinkEditor::Subl) + ); + assert_eq!(settings.revert().file_link_editor, FileLinkEditor::Default); +} + +#[test] +fn file_link_editor_save_undo_and_reset_preserve_unrelated_config() { + use noa_config::FileLinkEditor; + let dir = std::env::temp_dir().join(format!( + "noa-editor-settings-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("config"); + let original = "# keep this comment\nfile-link-editor = cursor\nfont-size = 19\n"; + std::fs::write(&path, original).unwrap(); + let mut settings = ThemeSettings::open(ThemeSettingsInit { + file_link_editor: FileLinkEditor::Cursor, + font_size: 19.0, + ..settings_init() + }); + move_to_row(&mut settings, SettingsRowKind::FileLinkEditor); + settings.adjust(1, Instant::now()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), original); + assert_eq!( + settings.commit_updates(), + vec![("file-link-editor".into(), "zed".into())] + ); + let mut writer = + |path: &Path, updates: &[(String, String)]| noa_config::write_config_updates(path, updates); + settings.commit(&path, &mut writer).unwrap(); + let saved = std::fs::read_to_string(&path).unwrap(); + assert!(saved.contains("file-link-editor = zed")); + assert!(saved.contains("# keep this comment")); + assert!(saved.contains("font-size = 19")); + let (snapshot, pair) = settings.pre_commit_snapshot(); + noa_config::write_config_updates(&path, &revert_updates(&snapshot, pair.as_ref())).unwrap(); + assert!( + std::fs::read_to_string(&path) + .unwrap() + .contains("file-link-editor = cursor") + ); + settings.reset_selected_row(Instant::now()); + assert_eq!( + settings.commit_updates(), + vec![("file-link-editor".into(), "default".into())] + ); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn agent_workflow_guide_opens_without_changing_pending_settings() { + let mut settings = ThemeSettings::open(settings_init()); + move_to_row(&mut settings, SettingsRowKind::FileLinkEditor); + settings.adjust(1, Instant::now()); + move_to_row(&mut settings, SettingsRowKind::AgentWorkflowGuide); + let before = settings.view_fingerprint_u64(); + let pending = settings.commit_updates(); + assert_eq!( + settings.adjust(1, Instant::now()), + RowEffect::OpenAgentWorkflowGuide + ); + assert_eq!( + settings.adjust(-1, Instant::now()), + RowEffect::OpenAgentWorkflowGuide + ); + assert_eq!(settings.reset_selected_row(Instant::now()), RowEffect::None); + assert_eq!(settings.view_fingerprint_u64(), before); + assert_eq!(settings.commit_updates(), pending); + assert!(!settings.rows()[row_index(SettingsRowKind::AgentWorkflowGuide)].touched); +} + fn settings_init() -> ThemeSettingsInit { ThemeSettingsInit { mode: ThemeSettingsMode::Settings, @@ -2562,11 +2671,11 @@ fn default_for_maps_every_row_kind_to_its_documented_startup_default() { // the send-selection-send-enter row brings it to 29 (+1), the Remote // App QR action brings it to 30 (+1), the `glassmorphism` row brings it to // 33 (+1 on top of the scratch-terminal rows), and the `scrollback-persist` -// row brings the array to its current length (+1). +// row brings it to 34. File Link Editor and Agent Workflows add two rows. #[test] fn settings_row_kind_count_includes_remote_app_qr_action() { - assert_eq!(SettingsRowKind::COUNT, 34); - assert_eq!(SettingsRowKind::ALL.len(), 34); + assert_eq!(SettingsRowKind::COUNT, 36); + assert_eq!(SettingsRowKind::ALL.len(), 36); } // settings-panel-server-status: the status row is read-only (mirrors @@ -3201,6 +3310,7 @@ fn reset_selected_row_writes_default_for_and_marks_touched_for_every_row_kind() SettingsRowKind::ServerTokenCopy | SettingsRowKind::ServerRemoteAppQr | SettingsRowKind::ServerStatus + | SettingsRowKind::AgentWorkflowGuide ) { assert_eq!( settings.rows()[idx].draft, @@ -3698,7 +3808,7 @@ fn every_mutator_that_changes_state_changes_the_fingerprint() { } // This action mutates no pure state: App presents the QR outside // the state machine, so its RowEffect is tested separately. - SettingsRowKind::ServerRemoteAppQr => {} + SettingsRowKind::ServerRemoteAppQr | SettingsRowKind::AgentWorkflowGuide => {} // Same shape as `ServerTokenCopy` above, but for the read-only // status row's own out-of-band refresh (see its doc comment on // `SettingsRowKind::ServerStatus`). @@ -3725,7 +3835,10 @@ fn every_mutator_that_changes_state_changes_the_fingerprint() { let before_edit = settings.view_fingerprint_u64(); exercise(&mut settings, *kind, Instant::now()); - if *kind == SettingsRowKind::ServerRemoteAppQr { + if matches!( + kind, + SettingsRowKind::ServerRemoteAppQr | SettingsRowKind::AgentWorkflowGuide + ) { continue; } assert_ne!( @@ -4053,6 +4166,7 @@ fn sample_revert(theme_name: &str) -> RevertValues { glassmorphism: GlassLevel::Off, confirm_quit: true, send_selection_send_enter: false, + file_link_editor: noa_config::FileLinkEditor::Default, font_family: "Menlo".to_string(), } } diff --git a/docs/AGENT_WORKFLOW.md b/docs/AGENT_WORKFLOW.md index 8282c096..5a8141d7 100644 --- a/docs/AGENT_WORKFLOW.md +++ b/docs/AGENT_WORKFLOW.md @@ -1,5 +1,18 @@ # Coding agent workflows +## Settings panel + +Open **Settings** with **Cmd+,**. Use **Tab** to search for **File Link Editor**, +then **Enter** to select the row. Use **Left/Right** to choose **System Default**, +**VS Code**, **Cursor**, **Zed**, or **Sublime Text**, and **Enter** to save. +The editor changes on save without restarting Noa. **Escape** cancels changes; +**Delete** or **Cmd+Backspace** resets to System Default. **Cmd+Z** on the saved +settings toast restores the previous editor. + +The **Agent Workflows** row opens this built-in guide with **Left/Right**. +Opening it keeps unsaved settings intact. Close the guide and Settings before +running the prompt/reader commands below from the command palette. + ## Notifications Unread notifications belong to individual panes. Selecting a pane acknowledges @@ -49,6 +62,8 @@ updated output. ## File navigation and preview +Choose **File Link Editor** in Settings, or configure it directly: + ```conf file-link-editor = code ``` diff --git a/docs/specs/agent-workflow.md b/docs/specs/agent-workflow.md index b8484e9a..0edc60ae 100644 --- a/docs/specs/agent-workflow.md +++ b/docs/specs/agent-workflow.md @@ -33,18 +33,21 @@ Implemented: - Explicit agent states and a Claude Code lifecycle hook adapter. - Native prompt drafts, read-only output snapshots, Find, and fenced code copying. Draft paste follows the existing protection path and never sends Enter. +- Settings exposes File Link Editor with save/cancel/reset/Undo support, plus + an Agent Workflows action that opens the bundled guide without saving drafts. Verification: - `cargo test --workspace`: passed with local IPC sockets permitted. - `cargo test -p noa-app --lib --quiet`: final app changes passed; - 1,178 tests passed, 6 ignored. + 1,182 tests passed, 6 ignored, including Settings search, editor save/reset/Undo, + and preserving pending settings when opening the guide. - `cargo build --workspace`: passed. - `cargo fmt --all -- --check` and `git diff --check`: passed. - `python3 -B -m unittest discover -s scripts -p test_noa_agent_hook.py`: 4 tests passed. - `bash scripts/test-native-text-panels.sh`: passed on macOS. Synthetic panels - verify Japanese draft preservation, reader mode, native Find/selection routing, + verify Japanese draft preservation, reader/guide modes, native Find/selection routing, and clean closure without launching a shell or modifying the clipboard. Verification limits: interactive Japanese IME candidate selection, installed diff --git a/scripts/test-native-text-panels.sh b/scripts/test-native-text-panels.sh index 42981ccd..0b2c868e 100644 --- a/scripts/test-native-text-panels.sh +++ b/scripts/test-native-text-panels.sh @@ -24,4 +24,4 @@ PLIST open -W -n "$smoke_app" --stdout "$smoke_dir/stdout" --stderr "$smoke_dir/stderr" cat "$smoke_dir/stdout" "$smoke_dir/stderr" # open reports successful launching even when the child process failed an assertion. -grep -Fq 'Native composer, Japanese draft, reader, find routing, and close checks passed.' "$smoke_dir/stdout" +grep -Fq 'Native composer, Japanese draft, reader, guide, find routing, and close checks passed.' "$smoke_dir/stdout" From 8bcc3b0b50b91c10f2068d3a0c9ce4458699d431 Mon Sep 17 00:00:00 2001 From: "shingo.imota" Date: Sat, 5 Sep 2026 11:44:52 +0900 Subject: [PATCH 3/4] docs: add agent workflow screenshots Include synthetic native text-view captures so reviewers can inspect prompt composition and output readability. --- docs/images/README.md | 5 +++++ docs/images/agent-compose-prompt.png | Bin 0 -> 49559 bytes docs/images/agent-output-reader.png | Bin 0 -> 60565 bytes 3 files changed, 5 insertions(+) create mode 100644 docs/images/README.md create mode 100644 docs/images/agent-compose-prompt.png create mode 100644 docs/images/agent-output-reader.png diff --git a/docs/images/README.md b/docs/images/README.md new file mode 100644 index 00000000..7e73113d --- /dev/null +++ b/docs/images/README.md @@ -0,0 +1,5 @@ +# Agent workflow screenshots + +These images capture native text views using the synthetic content from +`crates/noa-app/examples/native-text-panels.rs`. They show the prompt composer +and output reader; window chrome and Find controls are outside the captured area. diff --git a/docs/images/agent-compose-prompt.png b/docs/images/agent-compose-prompt.png new file mode 100644 index 0000000000000000000000000000000000000000..7200448ecb9452a54533742117859e7e37c764c5 GIT binary patch literal 49559 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 literal 0 HcmV?d00001 diff --git a/docs/images/agent-output-reader.png b/docs/images/agent-output-reader.png new file mode 100644 index 0000000000000000000000000000000000000000..ab90c4bbdb761f6e6b3e0252ac3517272294aece GIT binary patch literal 60565 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 Pc7Vat)z4*}Q$iB}8`xK> literal 0 HcmV?d00001 From aae3461f5f42030d5352b97a538d76c1eaa817d0 Mon Sep 17 00:00:00 2001 From: "shingo.imota" Date: Sat, 5 Sep 2026 12:05:55 +0900 Subject: [PATCH 4/4] fix(app): preserve commands while text panels have focus Pass unhandled commands through to app dispatch so modeless panels do not suppress settings, new windows, or notification navigation. Cover passthrough in the native panel smoke check. --- crates/noa-app/examples/native-text-panels.rs | 12 ++++++++++++ crates/noa-app/src/text_panel.rs | 3 +-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/noa-app/examples/native-text-panels.rs b/crates/noa-app/examples/native-text-panels.rs index 58397016..e3197741 100644 --- a/crates/noa-app/examples/native-text-panels.rs +++ b/crates/noa-app/examples/native-text-panels.rs @@ -71,6 +71,18 @@ fn main() { ); 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 for Smoke { diff --git a/crates/noa-app/src/text_panel.rs b/crates/noa-app/src/text_panel.rs index 34a43214..5d30c8f3 100644 --- a/crates/noa-app/src/text_panel.rs +++ b/crates/noa-app/src/text_panel.rs @@ -410,8 +410,7 @@ mod native { let _: () = msg_send![&*self.find_button, setTag: 1_isize]; } AppCommand::CloseTab | AppCommand::CloseWindow => self.close(), - AppCommand::Quit | AppCommand::About => return false, - _ => {} + _ => return false, } true }