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 0000000..e319774 --- /dev/null +++ b/crates/noa-app/examples/native-text-panels.rs @@ -0,0 +1,203 @@ +//! Explicit GUI smoke check with synthetic content, without a shell/clipboard writes. + +#[cfg(target_os = "macos")] +pub use noa_app::{AppCommand, UserEvent, split_tree}; +#[cfg(target_os = "macos")] +mod commands { + pub use noa_app::{SearchAction, TerminalAction}; +} +#[cfg(target_os = "macos")] +#[path = "../src/text_panel.rs"] +mod text_panel; + +#[cfg(target_os = "macos")] +fn main() { + use objc2::{ + msg_send, + runtime::{AnyClass, AnyObject}, + }; + use objc2_foundation::{NSRange, NSString}; + use std::time::{Duration, Instant}; + use text_panel::{TextPanel, TextPanelMode}; + use winit::{ + application::ApplicationHandler, + event::WindowEvent, + event_loop::{ActiveEventLoop, ControlFlow, EventLoop, EventLoopProxy}, + platform::macos::{ActivationPolicy, EventLoopBuilderExtMacOS}, + window::WindowId, + }; + + const PROMPT: &str = + "日本語の指示を編集します。\n\n> error at src/main.rs:42\n\nPlease fix the regression."; + struct Smoke { + panel: Option, + 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())); + 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 { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + unsafe { + let app: *mut AnyObject = + msg_send![AnyClass::get(c"NSApplication").unwrap(), sharedApplication]; + let _: () = msg_send![app, activateIgnoringOtherApps: true]; + } + self.panel = Some( + TextPanel::open( + "Compose Prompt — Sample / feature/test", + PROMPT, + TextPanelMode::Compose, + WindowId::from(1u64), + split_tree::PaneId::new(1), + None, + self.proxy.clone(), + ) + .unwrap(), + ); + self.ready = Instant::now() + Duration::from_millis(300); + self.focus_deadline = Instant::now() + Duration::from_secs(10); + event_loop.set_control_flow(ControlFlow::WaitUntil(self.ready)); + } + fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {} + fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + if self.phase == 6 { + return; + } + if Instant::now() < self.ready { + event_loop.set_control_flow(ControlFlow::WaitUntil(self.ready)); + return; + } + let panel = self.panel.as_ref().unwrap(); + if self.phase == 0 || self.phase == 2 || self.phase == 4 { + if !panel.handle_command(AppCommand::Search(commands::SearchAction::Find)) { + assert!( + Instant::now() < self.focus_deadline, + "native panel did not gain focus in phase {}", + self.phase + ); + activate_panel(match self.phase { + 0 => "Compose Prompt", + 2 => "Output Snapshot", + _ => "Agent Workflows", + }); + self.ready = Instant::now() + Duration::from_millis(100); + event_loop.set_control_flow(ControlFlow::WaitUntil(self.ready)); + return; + } + self.phase += 1; + self.ready = Instant::now() + Duration::from_millis(300); + event_loop.set_control_flow(ControlFlow::WaitUntil(self.ready)); + } else if self.phase == 1 { + check_find_selection(panel); + assert_eq!( + panel.draft(), + Some((split_tree::PaneId::new(1), PROMPT.to_string())) + ); + panel.close(); + self.panel = Some(TextPanel::open("Output Snapshot — Sample", "# Result\n\n日本語と English の説明。\n\n```rust\nfn main() {\n println!(\"Hello\");\n}\n```\n\nThe terminal keeps running while this snapshot stays still.", + TextPanelMode::Output, WindowId::from(1u64), split_tree::PaneId::new(1), None, self.proxy.clone()).unwrap()); + self.phase = 2; + self.focus_deadline = Instant::now() + Duration::from_secs(10); + self.ready = Instant::now() + Duration::from_millis(300); + event_loop.set_control_flow(ControlFlow::WaitUntil(self.ready)); + } else { + check_find_selection(panel); + assert!(panel.draft().is_none()); + panel.note_output(split_tree::PaneId::new(1)); + panel.close(); + if self.phase == 3 { + self.panel = Some( + TextPanel::open( + "Agent Workflows — Sample", + include_str!("../../../docs/AGENT_WORKFLOW.md"), + TextPanelMode::Guide, + WindowId::from(1u64), + split_tree::PaneId::new(1), + None, + self.proxy.clone(), + ) + .unwrap(), + ); + self.phase = 4; + self.focus_deadline = Instant::now() + Duration::from_secs(10); + self.ready = Instant::now() + Duration::from_millis(300); + event_loop.set_control_flow(ControlFlow::WaitUntil(self.ready)); + } else { + self.phase = 6; + event_loop.exit(); + } + } + } + } + let event_loop = EventLoop::::with_user_event() + .with_activation_policy(ActivationPolicy::Regular) + .build() + .unwrap(); + let mut smoke = Smoke { + panel: None, + proxy: event_loop.create_proxy(), + phase: 0, + ready: Instant::now(), + focus_deadline: Instant::now(), + }; + event_loop.run_app(&mut smoke).unwrap(); + assert_eq!(smoke.phase, 6); + println!( + "Native composer, Japanese draft, reader, guide, find routing, and close checks passed." + ); +} + +#[cfg(not(target_os = "macos"))] +fn main() { + println!("Native text panel smoke check requires macOS."); +} diff --git a/crates/noa-app/src/app.rs b/crates/noa-app/src/app.rs index 8569617..f7d3fb1 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 b93f780..7605f01 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 3d0e9e9..cd9e174 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 ac776ba..a0f9e3e 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 e519aa8..0f59aad 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 1b87ff6..2f39779 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 1c95136..d857136 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 0000000..e0996a9 --- /dev/null +++ b/crates/noa-app/src/app/input_ops/text_panel.rs @@ -0,0 +1,314 @@ +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, + 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/input_ops/theme_settings.rs b/crates/noa-app/src/app/input_ops/theme_settings.rs index 5cf033f..1c60d6f 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/overview/interaction.rs b/crates/noa-app/src/app/overview/interaction.rs index c1402b5..677cad8 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 597ed87..6265ada 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 08022d6..cc41b11 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 8fd0968..4c1a8bf 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 5b73fbe..8ff7ba1 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 d5ea8fe..c915208 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/app/state.rs b/crates/noa-app/src/app/state.rs index 2d4d6c2..06c53ca 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/cli.rs b/crates/noa-app/src/cli.rs index 8f82651..be7d368 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 90b7f8e..5d3e8c2 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 5b2947c..8eb6119 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 fdb9f61..c1c37c7 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 e9630d4..13fce37 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 51b45ea..b59da58 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 bf43ee4..f45626c 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 8d50468..6c47682 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 a0a63ba..126ec3a 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 e88b0b5..067f36d 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/macos_overlay/tests.rs b/crates/noa-app/src/macos_overlay/tests.rs index 6a0de6e..ab6e80a 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/session_store.rs b/crates/noa-app/src/session_store.rs index 1944479..1b7ff80 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 d82052b..fe9befd 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 4162930..35068db 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 0000000..5d30c8f --- /dev/null +++ b/crates/noa-app/src/text_panel.rs @@ -0,0 +1,467 @@ +//! 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, + Guide, +} + +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 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 { + 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 && 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)])?; + 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(), + _ => 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-app/src/theme_settings/rows.rs b/crates/noa-app/src/theme_settings/rows.rs index a08fe48..a94b8f7 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 34589db..cceb496 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 3eea469..ff99d91 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/crates/noa-config/src/lib.rs b/crates/noa-config/src/lib.rs index b891b9a..0b28264 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 b7024b4..80fd109 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 09bfb16..5a004f8 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 dd7882b..5f3ba8b 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 b91e4c7..9be4474 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 a43101e..861a611 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 19096fb..a60b92f 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 a4a19b7..a356840 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 0000000..5a8141d --- /dev/null +++ b/docs/AGENT_WORKFLOW.md @@ -0,0 +1,219 @@ +# 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 +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 + +Choose **File Link Editor** in Settings, or configure it directly: + +```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 7ef0a7b..9f59d4f 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 b528d0d..458e79a 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 1839f26..4277331 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/images/README.md b/docs/images/README.md new file mode 100644 index 0000000..7e73113 --- /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 0000000..7200448 Binary files /dev/null and b/docs/images/agent-compose-prompt.png differ diff --git a/docs/images/agent-output-reader.png b/docs/images/agent-output-reader.png new file mode 100644 index 0000000..ab90c4b Binary files /dev/null and b/docs/images/agent-output-reader.png differ diff --git a/docs/specs/agent-attention.md b/docs/specs/agent-attention.md index 7f81627..0305e7b 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 0000000..0edc60a --- /dev/null +++ b/docs/specs/agent-workflow.md @@ -0,0 +1,56 @@ +# 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. +- 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,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/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 +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 0000000..7c05342 --- /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 0000000..0b2c868 --- /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, guide, 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 0000000..5c52a18 --- /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()