diff --git a/changelog.d/subagent-activity-preview.added.md b/changelog.d/subagent-activity-preview.added.md new file mode 100644 index 000000000..3f3bb29b7 --- /dev/null +++ b/changelog.d/subagent-activity-preview.added.md @@ -0,0 +1 @@ +Added clickable inline five-line activity previews for task and team cards, with sanitized tool summaries and explicit full-session navigation through the Ctrl+X picker. diff --git a/n00n-lua/src/api/agent.rs b/n00n-lua/src/api/agent.rs index 16e59b94c..3a8f55b91 100644 --- a/n00n-lua/src/api/agent.rs +++ b/n00n-lua/src/api/agent.rs @@ -1,7 +1,7 @@ //! `n00n.agent` exposes subagent primitives to Lua plugins. Policy (retries, //! validation, concurrency) lives in the task plugin, not here. -use std::collections::{HashMap, VecDeque}; +use std::collections::{HashMap, VecDeque, hash_map::Entry}; use std::pin::pin; use std::sync::Arc; use std::sync::Mutex; @@ -22,7 +22,7 @@ use n00n_agent::tools::{ }; use n00n_agent::{ Agent, AgentEvent, AgentInput, AgentMode, AgentParams, AgentRunParams, Envelope, EventSender, - History, SubagentInfo, ToolDoneEvent, + History, SubagentInfo, ToolDoneEvent, ToolStartEvent, }; use n00n_lua_macro::{lua_class, lua_fn, lua_table}; use n00n_providers::model::ModelTier; @@ -40,6 +40,24 @@ use crate::api::util::ctx::{AgentContext, LuaCtx}; const SESSION_CLOSED_ERR: &str = "session closed"; const DEFAULT_SESSION_AUDIENCE: ToolAudience = ToolAudience::GENERAL_SUB; const PROGRESS_MAX_RECENT: usize = 5; +const ACTIVITY_MESSAGE_MAX_CHARS: usize = 80; +const REDACTED: &str = "[REDACTED]"; +const SAFE_ACTIVITY_DESCRIPTION_TOOLS: &[&str] = &[ + "batch", + "code_execution", + "edit", + "glob", + "grep", + "index", + "memory", + "multiedit", + "question", + "read", + "skill", + "todo_write", + "view_image", + "write", +]; const PROGRESS_TIMEOUT_MS: u64 = 500; const STEERING_QUEUE_CAPACITY: usize = 32; @@ -549,13 +567,14 @@ async fn session( AgentEvent::Done { usage, .. } => { ti.fetch_add(usage.total_input(), Ordering::Relaxed); to.fetch_add(usage.output, Ordering::Relaxed); + progress.record_forwarder_barrier(); continue; } AgentEvent::ToolStart(e) => { - progress.set_current(&e.tool); + progress.record_start(e); } AgentEvent::ToolDone(e) => { - progress.add_recent(&e.tool); + progress.record_done(e); } AgentEvent::Error { .. } | AgentEvent::ToolOutput { .. } @@ -717,11 +736,159 @@ async fn dispatch_racing_live( } } +fn activity_message(event: &ToolStartEvent) -> Option { + if !SAFE_ACTIVITY_DESCRIPTION_TOOLS.contains(&event.tool.as_ref()) { + return None; + } + let rendered_header = event + .render_header + .as_ref() + .map(n00n_agent::BufferSnapshot::first_line_text); + rendered_header + .as_deref() + .filter(|text| !text.trim().is_empty()) + .or_else(|| (!event.summary.trim().is_empty()).then_some(event.summary.as_str())) + .or_else(|| { + event + .annotation + .as_deref() + .filter(|text| !text.trim().is_empty()) + }) + .map(sanitize_activity_message) +} + +fn sanitize_activity_message(raw: &str) -> String { + let words = raw.split_whitespace().collect::>(); + let mut sanitized = Vec::with_capacity(words.len()); + let mut index = 0; + while index < words.len() { + let word = words[index]; + if word.eq_ignore_ascii_case("bearer") { + sanitized.push(format!("Bearer {REDACTED}")); + index = index.saturating_add(2); + continue; + } + + let separator = word.find(['=', ':']); + let key = separator.map_or(word, |position| &word[..position]); + if is_sensitive_key(key) || is_sensitive_key(word) { + let separator_char = + separator.map_or('=', |position| word.as_bytes()[position] as char); + sanitized.push(format!("{key}{separator_char}{REDACTED}")); + let inline_value = separator.and_then(|position| word.get(position + 1..)); + index += 1; + if inline_value.is_some_and(|value| value.eq_ignore_ascii_case("bearer")) { + index = index.saturating_add(1).min(words.len()); + } else if inline_value.is_none_or(str::is_empty) { + if words + .get(index) + .is_some_and(|next| *next == "=" || *next == ":") + { + index += 1; + } + if words + .get(index) + .is_some_and(|next| next.eq_ignore_ascii_case("bearer")) + { + index += 1; + } + if index < words.len() { + index += 1; + } + } + continue; + } + + let secret_value = separator.map_or(word, |position| &word[position + 1..]); + if is_secret_token(secret_value) { + let prefix = separator.map_or("", |position| &word[..=position]); + sanitized.push(format!("{prefix}{REDACTED}")); + } else { + sanitized.push(word.to_owned()); + } + index += 1; + } + truncate_activity_message(&sanitized.join(" ")) +} + +fn is_sensitive_key(value: &str) -> bool { + let normalized: String = value + .chars() + .filter(char::is_ascii_alphanumeric) + .flat_map(char::to_lowercase) + .collect(); + [ + "apikey", + "accesstoken", + "authtoken", + "authorization", + "password", + "passwd", + "secret", + "privatekey", + "clientsecret", + ] + .iter() + .any(|key| normalized.contains(key)) +} + +fn is_secret_token(value: &str) -> bool { + let lower = value + .trim_matches(|character: char| !character.is_ascii_alphanumeric()) + .to_ascii_lowercase(); + ["sk-", "ghp_", "github_pat_", "glpat-", "xoxb-", "xoxp-"] + .iter() + .any(|prefix| lower.contains(prefix)) + || lower.starts_with("akia") + || lower.starts_with("aiza") +} + +fn truncate_activity_message(message: &str) -> String { + if message.chars().count() <= ACTIVITY_MESSAGE_MAX_CHARS { + return message.to_owned(); + } + let mut truncated: String = message + .chars() + .take(ACTIVITY_MESSAGE_MAX_CHARS.saturating_sub(1)) + .collect(); + truncated.push('…'); + truncated +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ActivityStatus { + Running, + Success, + Error, +} + +impl ActivityStatus { + const fn as_str(self) -> &'static str { + match self { + Self::Running => "running", + Self::Success => "success", + Self::Error => "error", + } + } +} + +#[derive(Debug, Clone)] +struct Activity { + id: String, + tool: String, + message: Option, + status: ActivityStatus, +} + struct ProgressState { current: Option, recent: VecDeque, + activities: VecDeque, done: bool, completed_count: u64, + active_activity_counts: HashMap, + turn_id: u64, + forwarded_barriers: u64, } struct Progress { @@ -729,58 +896,164 @@ struct Progress { state: Mutex, tx: flume::Sender<()>, rx: flume::Receiver<()>, + barrier_tx: flume::Sender<()>, + barrier_rx: flume::Receiver<()>, } impl Progress { fn new(start: Instant) -> Self { - let (tx, rx) = flume::unbounded(); + let (tx, rx) = flume::bounded(1); + let (barrier_tx, barrier_rx) = flume::bounded(1); Self { start, state: Mutex::new(ProgressState { current: None, recent: VecDeque::new(), + activities: VecDeque::new(), done: false, completed_count: 0, + active_activity_counts: HashMap::new(), + turn_id: 0, + forwarded_barriers: 0, }), tx, rx, + barrier_tx, + barrier_rx, } } fn notify(&self) { - let _ = self.tx.send(()); + let _ = self.tx.try_send(()); } - fn set_current(&self, tool: &str) { + fn begin_turn(&self) -> u64 { let mut state = self .state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - state.current = Some(tool.to_owned()); + state.turn_id = state.turn_id.saturating_add(1); + state.done = false; + state.current = None; + let turn_id = state.turn_id; drop(state); self.notify(); + turn_id } - fn add_recent(&self, tool: &str) { + fn next_forwarder_barrier(&self) -> u64 { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .forwarded_barriers + .saturating_add(1) + } + + fn record_forwarder_barrier(&self) { let mut state = self .state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - state.current = None; - state.completed_count += 1; + state.forwarded_barriers = state.forwarded_barriers.saturating_add(1); + drop(state); + let _ = self.barrier_tx.try_send(()); + } + + async fn wait_for_forwarder_barrier(&self, target: u64) { + loop { + let reached = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .forwarded_barriers + >= target; + if reached || self.barrier_rx.recv_async().await.is_err() { + return; + } + } + } + + fn record_start(&self, event: &ToolStartEvent) { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let active_count = state + .active_activity_counts + .entry(event.id.clone()) + .or_insert(0); + *active_count = active_count.saturating_add(1); + if state.activities.len() >= PROGRESS_MAX_RECENT { + state.activities.pop_front(); + } + state.activities.push_back(Activity { + id: event.id.clone(), + tool: event.tool.to_string(), + message: activity_message(event), + status: ActivityStatus::Running, + }); + state.current = Some(event.tool.to_string()); + drop(state); + self.notify(); + } + + fn record_done(&self, event: &ToolDoneEvent) { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match state.active_activity_counts.entry(event.id.clone()) { + Entry::Occupied(mut entry) if *entry.get() > 1 => { + *entry.get_mut() = entry.get().saturating_sub(1); + } + Entry::Occupied(entry) => { + entry.remove(); + } + Entry::Vacant(_) => return, + } + if let Some(activity) = state + .activities + .iter_mut() + .find(|activity| activity.id == event.id && activity.status == ActivityStatus::Running) + { + activity.status = if event.is_error { + ActivityStatus::Error + } else { + ActivityStatus::Success + }; + } + state.completed_count = state.completed_count.saturating_add(1); if state.recent.len() >= PROGRESS_MAX_RECENT { state.recent.pop_front(); } - state.recent.push_back(tool.to_owned()); + state.recent.push_back(event.tool.to_string()); + state.current = state + .activities + .iter() + .rev() + .find(|activity| activity.status == ActivityStatus::Running) + .map(|activity| activity.tool.clone()); drop(state); self.notify(); } - fn set_done(&self) { + fn set_current_done(&self) { + let turn_id = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .turn_id; + self.set_done(turn_id); + } + + fn set_done(&self, turn_id: u64) { let mut state = self .state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.turn_id != turn_id { + return; + } state.done = true; state.current = None; drop(state); @@ -824,7 +1097,7 @@ impl SessionState { return; } self.closed = true; - self.progress.set_done(); + self.progress.set_current_done(); self.parent_cancels.remove(&self.child_id); let messages = std::mem::replace(&mut self.history, History::new(Vec::new())).into_vec(); let _ = self.parent_event_tx.send(AgentEvent::SubagentHistory { @@ -913,6 +1186,7 @@ async fn prompt( if s.closed { return Ok((None, Some(SESSION_CLOSED_ERR.to_owned()))); } + let progress_turn = s.progress.begin_turn(); if s.subagent_info.get().is_none() { let _ = s.subagent_info.set(SubagentInfo { parent_tool_use_id: s.parent_tool_use_id.clone(), @@ -953,16 +1227,34 @@ async fn prompt( workflow: false, prompt: None, }; + let barrier_target = s.progress.next_forwarder_barrier(); let result = agent.run(input).await; drop(agent); + if result.is_err() + && let Err(barrier_error) = s.sub_event_tx.send(AgentEvent::Done { + usage: TokenUsage::default(), + num_turns: 0, + stop_reason: None, + }) + { + s.failed = true; + s.progress.set_done(progress_turn); + return Ok(( + None, + Some(format!( + "subagent event forwarder barrier failed: {barrier_error}" + )), + )); + } + s.progress.wait_for_forwarder_barrier(barrier_target).await; if let Err(e) = result { s.failed = true; - s.progress.set_done(); + s.progress.set_done(progress_turn); return Ok((None, Some(e.to_string()))); } next_message = s.prompt_rx.try_recv().ok(); } - s.progress.set_done(); + s.progress.set_done(progress_turn); let text = s .history @@ -995,8 +1287,10 @@ async fn prompt( /// `elapsed_ms` (integer): time since the session was created. /// `current_tool` (string?): name of the tool currently running, if any. /// `recent_tools` (table): names of the last few finished tools, oldest first. +/// `activities` (table): up to five safe rendered tool summaries, oldest first. /// `completed_count` (integer): total number of finished tools so far. -/// `done` (bool): true once the prompt has completed. +/// `turn_id` (integer): increases before each `prompt` call. +/// `done` (bool): true once the current prompt call has completed. /// /// The call returns at most every `PROGRESS_TIMEOUT_MS` milliseconds, or /// immediately when a tool starts or finishes. @@ -1021,12 +1315,24 @@ async fn get_progress(lua: Lua, this: mlua::UserDataRef) -> LuaResul tbl.set("current_tool", state.current.as_deref())?; tbl.set("done", state.done)?; tbl.set("completed_count", state.completed_count)?; + tbl.set("turn_id", state.turn_id)?; let recent = lua.create_table()?; for (i, tool) in state.recent.iter().enumerate() { recent.set(i + 1, tool.as_str())?; } tbl.set("recent_tools", recent)?; + + let activities = lua.create_table()?; + for (i, activity) in state.activities.iter().enumerate() { + let item = lua.create_table()?; + item.set("id", activity.id.as_str())?; + item.set("tool", activity.tool.as_str())?; + item.set("message", activity.message.as_deref())?; + item.set("status", activity.status.as_str())?; + activities.set(i + 1, item)?; + } + tbl.set("activities", activities)?; Ok((Some(tbl), None)) } @@ -1081,6 +1387,7 @@ fn call_local_tool( #[cfg(test)] mod tests { + use n00n_agent::ToolOutput; use serde_json::json; use super::*; @@ -1091,6 +1398,215 @@ mod tests { call_local_tool(&lua.weak(), &f, input) } + fn progress_start(id: &str, summary: &str) -> ToolStartEvent { + ToolStartEvent { + id: id.into(), + tool: Arc::from("read"), + summary: summary.into(), + render_header: None, + annotation: None, + input: None, + raw_input: Some(json!({"secret": "must not be read"})), + output: Some(ToolOutput::Plain("must not be read".into())), + } + } + + fn progress_done(id: &str, is_error: bool) -> ToolDoneEvent { + ToolDoneEvent { + id: id.into(), + tool: Arc::from("read"), + output: ToolOutput::Plain("must not be read".into()), + is_error, + annotation: Some("must not be read".into()), + written_path: None, + } + } + + #[test] + fn progress_updates_start_in_place_and_retains_status() { + let progress = Progress::new(Instant::now()); + progress.record_start(&progress_start("one", "cargo test")); + progress.record_done(&progress_done("one", true)); + progress.record_done(&progress_done("one", true)); + + let state = progress + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(state.completed_count, 1); + assert_eq!(state.activities.len(), 1); + assert_eq!(state.activities[0].message.as_deref(), Some("cargo test")); + assert_eq!(state.activities[0].status, ActivityStatus::Error); + assert!(state.active_activity_counts.is_empty()); + } + + #[test] + fn progress_tracks_reused_activity_ids_in_order() { + let progress = Progress::new(Instant::now()); + progress.record_start(&progress_start("call_read", "first")); + progress.record_start(&progress_start("call_read", "second")); + progress.record_done(&progress_done("call_read", false)); + progress.record_done(&progress_done("call_read", true)); + + let state = progress + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(state.completed_count, 2); + assert_eq!(state.activities.len(), 2); + assert_eq!(state.activities[0].message.as_deref(), Some("first")); + assert_eq!(state.activities[0].status, ActivityStatus::Success); + assert_eq!(state.activities[1].message.as_deref(), Some("second")); + assert_eq!(state.activities[1].status, ActivityStatus::Error); + assert!(state.active_activity_counts.is_empty()); + } + + #[test] + fn progress_keeps_latest_five_activity_rows() { + let progress = Progress::new(Instant::now()); + for i in 0..7 { + let id = i.to_string(); + progress.record_start(&progress_start(&id, &format!("message {i}"))); + progress.record_done(&progress_done(&id, false)); + } + + let state = progress + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(state.activities.len(), PROGRESS_MAX_RECENT); + assert_eq!(state.activities[0].message.as_deref(), Some("message 2")); + assert_eq!(state.activities[4].message.as_deref(), Some("message 6")); + assert!( + state + .activities + .iter() + .all(|activity| activity.status == ActivityStatus::Success) + ); + } + + #[test] + fn progress_activity_message_is_sanitized_and_truncated() { + let progress = Progress::new(Instant::now()); + let long_secret = format!("API_KEY=super-secret\n{}", "é".repeat(100)); + progress.record_start(&progress_start("secret", &long_secret)); + + let state = progress + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let message = state.activities[0].message.as_deref().unwrap(); + assert!(!message.contains("super-secret")); + assert!(!message.contains('\n')); + assert!(message.ends_with('…')); + assert!(message.chars().count() <= ACTIVITY_MESSAGE_MAX_CHARS); + } + + #[test] + fn progress_counts_done_events_for_evicted_activity_rows() { + let progress = Progress::new(Instant::now()); + for i in 0..7 { + progress.record_start(&progress_start(&i.to_string(), "running")); + } + for i in 0..7 { + progress.record_done(&progress_done(&i.to_string(), false)); + } + + let state = progress + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(state.completed_count, 7); + assert_eq!(state.recent.len(), PROGRESS_MAX_RECENT); + assert!( + state + .activities + .iter() + .all(|activity| activity.status == ActivityStatus::Success) + ); + } + + #[test] + fn forwarder_barrier_observes_prior_tool_done() { + smol::block_on(async { + let progress = Arc::new(Progress::new(Instant::now())); + progress.record_start(&progress_start("one", "reading")); + let target = progress.next_forwarder_barrier(); + let forwarded = Arc::clone(&progress); + let worker = smol::spawn(async move { + forwarded.record_done(&progress_done("one", false)); + forwarded.record_forwarder_barrier(); + }); + + progress.wait_for_forwarder_barrier(target).await; + worker.await; + let state = progress + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(state.activities[0].status, ActivityStatus::Success); + }); + } + + #[test] + fn progress_redacts_separated_credentials() { + let sanitized = sanitize_activity_message( + "API_KEY = first Authorization: Bearer second --password third foo=sk-secret", + ); + assert_eq!( + sanitized, + "API_KEY=[REDACTED] Authorization:[REDACTED] --password=[REDACTED] foo=[REDACTED]" + ); + } + + #[test] + fn progress_redacts_adjacent_bearer_scheme_and_token() { + let sanitized = sanitize_activity_message("Authorization:Bearer visible-token trailing"); + assert_eq!(sanitized, "Authorization:[REDACTED] trailing"); + } + + #[test] + fn progress_redacts_credentials_embedded_in_urls_and_tokens() { + let sanitized = sanitize_activity_message( + "https://host.test/path?api_key=visible glpat-visible prefix-ghp_visible", + ); + assert_eq!(sanitized, "https:[REDACTED] [REDACTED] [REDACTED]"); + } + + #[test] + fn progress_notifications_are_coalesced() { + let progress = Progress::new(Instant::now()); + for _ in 0..100 { + progress.notify(); + progress.record_forwarder_barrier(); + } + assert_eq!(progress.rx.len(), 1); + assert_eq!(progress.barrier_rx.len(), 1); + } + + #[test] + fn progress_hides_bash_and_unknown_tool_headers() { + for tool in ["bash", "custom_plugin"] { + let mut event = progress_start(tool, "API_KEY=visible"); + event.tool = Arc::from(tool); + event.render_header = Some(n00n_agent::BufferSnapshot::plain_text( + "Authorization: Bearer rendered-secret".into(), + )); + assert_eq!(activity_message(&event), None); + } + } + + #[test] + fn progress_done_is_turn_safe() { + let progress = Progress::new(Instant::now()); + let first = progress.begin_turn(); + let second = progress.begin_turn(); + progress.set_done(first); + assert!(!progress.state.lock().unwrap().done); + progress.set_done(second); + assert!(progress.state.lock().unwrap().done); + } + #[test] fn usage_cost_accepts_spec_without_context() { let lua = Lua::new(); diff --git a/n00n-lua/tests/task_policy.rs b/n00n-lua/tests/task_policy.rs index 42fc61bea..a05f7e169 100644 --- a/n00n-lua/tests/task_policy.rs +++ b/n00n-lua/tests/task_policy.rs @@ -145,13 +145,14 @@ n00n.agent.session = function(ctx, opts) recorder.sessions = recorder.sessions + 1 recorder.has_local_tools = opts.local_tools ~= nil recorder.structured_output_schema = opts.local_tools and opts.local_tools.structured_output and opts.local_tools.structured_output.input_schema - local sess = { opts = opts, ctx = ctx } + local sess = { opts = opts, ctx = ctx, turn = 0 } function sess:prompt(msg) + self.turn = self.turn + 1 recorder.prompts[#recorder.prompts + 1] = msg return behaviors[opts.name](self, msg) end function sess:get_progress() - return { completed_count = 0, recent_tools = {}, elapsed_ms = 0, done = true } + return { turn_id = self.turn, completed_count = 0, recent_tools = {}, activities = {}, elapsed_ms = 0, done = true } end function sess:close() recorder.closed = recorder.closed + 1 diff --git a/n00n-ui/src/app/mouse.rs b/n00n-ui/src/app/mouse.rs index 4a3db9294..4cd9f13c8 100644 --- a/n00n-ui/src/app/mouse.rs +++ b/n00n-ui/src/app/mouse.rs @@ -84,17 +84,6 @@ impl App { { self.copy_text(&text, format!("Copied {label}")); } else { - let session = self.chats[render_chat] - .tool_id_at(event.row, area) - .and_then(|id| { - self.chats.iter().position(|chat| { - chat.tool_use_id.as_deref() == Some(id) - }) - }); - if let Some(idx) = session { - self.active_chat = idx; - return; - } self.chats[render_chat].handle_click(event.row, area); } } diff --git a/n00n-ui/src/app/tests.rs b/n00n-ui/src/app/tests.rs index 338a21c2e..bf3eecfbe 100644 --- a/n00n-ui/src/app/tests.rs +++ b/n00n-ui/src/app/tests.rs @@ -1003,7 +1003,7 @@ fn completed_subagent_chat_remains_discoverable_by_tool_id() { } #[test] -fn completed_task_body_click_expands_while_header_navigates() { +fn completed_task_card_click_toggles_inline_without_navigating() { let mut app = test_app(); app.status = Status::Streaming; app.run_id = 1; @@ -1041,18 +1041,18 @@ fn completed_task_body_click_expands_while_header_navigates() { app.update(mouse_event( MouseEventKind::Down(MouseButton::Left), 10, - area.y + 1, + area.y, )); app.update(mouse_event( MouseEventKind::Up(MouseButton::Left), 10, - area.y + 1, + area.y, )); - assert_eq!(app.active_chat, 0, "task body must not navigate"); + assert_eq!(app.active_chat, 0, "task card click must not navigate"); terminal.draw(|frame| app.view(frame)).unwrap(); assert!( app.chats[0].total_lines() > collapsed_lines, - "task body click must expand the completed task" + "task card click must expand inline" ); app.update(mouse_event( @@ -1065,7 +1065,12 @@ fn completed_task_body_click_expands_while_header_navigates() { 10, area.y, )); - assert_eq!(app.active_chat, 1, "task header navigates to child chat"); + assert_eq!( + app.active_chat, 0, + "repeated task card click must not navigate" + ); + terminal.draw(|frame| app.view(frame)).unwrap(); + assert_eq!(app.chats[0].total_lines(), collapsed_lines); } fn open_tasks_picker(app: &mut App) { @@ -1160,7 +1165,7 @@ fn task_picker_preview_copy_uses_rendered_chat() { } #[test] -fn task_picker_preview_tool_click_uses_rendered_chat() { +fn nested_task_preview_click_stays_inline_and_picker_enter_navigates() { let mut app = app_with_subagent(); app.update(subagent_msg( AgentEvent::ToolStart(Box::new(ToolStartEvent { @@ -1202,7 +1207,21 @@ fn task_picker_preview_tool_click_uses_rendered_chat() { tool_row, )); - assert_eq!(app.active_chat, 2); + assert_eq!( + app.active_chat, 0, + "nested task card click must not navigate" + ); + + app.update(Msg::Key(kb::TASKS.to_key_event())); + assert!(!app.task_picker.is_open()); + app.update(Msg::Key(kb::TASKS.to_key_event())); + app.update(Msg::Key(key(KeyCode::Down))); + app.update(Msg::Key(key(KeyCode::Down))); + app.update(Msg::Key(key(KeyCode::Enter))); + assert_eq!( + app.active_chat, 2, + "Ctrl+X and Enter must navigate explicitly" + ); } #[test] diff --git a/plugins/lib/n00n/activity_preview.lua b/plugins/lib/n00n/activity_preview.lua new file mode 100644 index 000000000..4b77d4def --- /dev/null +++ b/plugins/lib/n00n/activity_preview.lua @@ -0,0 +1,135 @@ +local ToolView = require("n00n.tool_view") + +local ActivityPreview = {} +ActivityPreview.__index = ActivityPreview + +local MAX_ROWS = 5 +local MAX_LINE_BYTES = 500 +local BODY_INDENT_COLS = 4 + +local function status_style(status) + if status == "error" then + return "error" + end + if status == "running" then + return "bold" + end + return "dim" +end + +function ActivityPreview.new(ctx, description, opts) + local width = math.max(n00n.ui.terminal_size().cols - BODY_INDENT_COLS, 1) + local view = ToolView.new(n00n.ui.buf(), { + max_lines = MAX_ROWS, + max_expand_lines = MAX_ROWS, + max_line_bytes = MAX_LINE_BYTES, + max_width = width, + keep = "tail", + hide_collapsed = true, + }) + local self = setmetatable({ + description = description, + started_at = os.time(), + view = view, + rows = {}, + row_index = {}, + activity_ids = {}, + turns = {}, + prompt_serial = 0, + session_rows = opts and opts.session_rows or false, + }, ActivityPreview) + view.buf:on("click", function() + view:toggle() + end) + self:render() + local _, live_err = ctx:live_buf(view.buf) + if live_err then + return nil, live_err + end + return self, nil +end + +function ActivityPreview:render() + local elapsed = math.max(os.time() - self.started_at, 0) + self.view:set_header({ { { self.description .. " · " .. n00n.ui.humantime(elapsed), "bold" } } }) + self.view:clear() + for _, row in ipairs(self.rows) do + local detail = row.message or row.status + self.view:append({ { row.label .. " - " .. detail, status_style(row.status) } }) + end +end + +function ActivityPreview:set_row(key, label, message, status) + local row = self.row_index[key] + if row then + row.label = label + row.message = message + row.status = status + else + row = { key = key, label = label, message = message, status = status } + self.rows[#self.rows + 1] = row + self.row_index[key] = row + if #self.rows > MAX_ROWS then + local removed = table.remove(self.rows, 1) + self.row_index[removed.key] = nil + end + end + self:render() +end + +function ActivityPreview:update(progress, label, session_key) + local previous_ids = self.activity_ids[session_key] or {} + local current_ids = {} + for _, activity in ipairs(progress.activities or {}) do + local activity_key = session_key .. "/" .. (activity.id or activity.tool) + current_ids[activity_key] = true + if self.row_index[activity_key] or not previous_ids[activity_key] then + local row_label = activity.message and (label .. "/" .. activity.tool) or activity.tool + self:set_row(activity_key, row_label, activity.message, activity.status or "running") + end + end + self.activity_ids[session_key] = current_ids +end + +function ActivityPreview:prompt(sess, message, label) + self.prompt_serial = self.prompt_serial + 1 + local prompt_key = label .. "#" .. self.prompt_serial + local session_key = tostring(sess) + local baseline_turn = self.turns[session_key] or 0 + if self.session_rows then + self:set_row(prompt_key, label, nil, "running") + end + + local result, prompt_err + local function run_prompt() + result, prompt_err = sess:prompt(message) + end + local function poll_progress() + while true do + local progress, progress_err = sess:get_progress() + if not progress then + error(progress_err or "session progress unavailable", 0) + end + if (progress.turn_id or 0) > baseline_turn then + self.turns[session_key] = progress.turn_id + self:update(progress, label, session_key) + if progress.done then + return + end + end + end + end + + local gathered = n00n.async.gather({ run_prompt, poll_progress }) + if not gathered[1].ok then + prompt_err = gathered[1].err + elseif not gathered[2].ok and not prompt_err then + prompt_err = gathered[2].err + end + if self.session_rows then + self:set_row(prompt_key, label, nil, prompt_err and "error" or "success") + end + return result, prompt_err +end + +return ActivityPreview diff --git a/plugins/lib/n00n/tool_view.lua b/plugins/lib/n00n/tool_view.lua index 39513785b..032144805 100644 --- a/plugins/lib/n00n/tool_view.lua +++ b/plugins/lib/n00n/tool_view.lua @@ -20,9 +20,10 @@ local function line_nr_fmt(count) return "%" .. w .. "d " end --- Truncate a UTF-8 string at a byte boundary that is also a codepoint --- boundary, appending an ellipsis if truncation happened. -local function utf8_truncate_bytes(s, max_bytes) +local ELLIPSIS = "…" +local ELLIPSIS_BYTES = #ELLIPSIS + +local function utf8_prefix_bytes(s, max_bytes) if #s <= max_bytes then return s end @@ -37,67 +38,72 @@ local function utf8_truncate_bytes(s, max_bytes) end i = i - 1 end - if i <= 0 then - return "…" - end - return s:sub(1, i) .. "…" + return s:sub(1, i) end -local function line_text_bytes(line) +local function line_metrics(line) if type(line) == "string" then - return #line + return #line, n00n.ui.display_width(line) end - local n = 0 + local bytes, width = 0, 0 for _, span in ipairs(line) do - if type(span) == "string" then - n = n + #span - else - n = n + #(span[1] or "") - end + local text = type(span) == "string" and span or (span[1] or "") + bytes = bytes + #text + width = width + n00n.ui.display_width(text) + end + return bytes, width +end + +local function clip_text(text, max_bytes, max_width) + local clipped = utf8_prefix_bytes(text, max_bytes) + if n00n.ui.display_width(clipped) > max_width then + clipped = n00n.ui.truncate_text(clipped, max_width).head end - return n + return clipped end -local function truncate_line(line, max_bytes) - if not max_bytes or max_bytes <= 0 then +local function truncate_line(line, max_bytes, max_width) + local byte_limit = max_bytes and math.max(max_bytes, 0) or math.huge + local width_limit = max_width and math.max(max_width, 0) or math.huge + local bytes, width = line_metrics(line) + if bytes <= byte_limit and width <= width_limit then return line end + + local add_ellipsis = byte_limit >= ELLIPSIS_BYTES and width_limit >= 1 + local byte_budget = byte_limit - (add_ellipsis and ELLIPSIS_BYTES or 0) + local width_budget = width_limit - (add_ellipsis and 1 or 0) + local used_bytes, used_width = 0, 0 + if type(line) == "string" then - return utf8_truncate_bytes(line, max_bytes) - end - if line_text_bytes(line) <= max_bytes then - return line + local head = clip_text(line, byte_budget, width_budget) + return head .. (add_ellipsis and ELLIPSIS or "") end + local out = {} - local used = 0 for _, span in ipairs(line) do - if used >= max_bytes then + if used_bytes >= byte_budget or used_width >= width_budget then break end - local text, style - if type(span) == "string" then - text = span - else - text = span[1] or "" - style = span[2] - end - local remaining = max_bytes - used - if #text > remaining then - text = utf8_truncate_bytes(text, remaining) - end - if style then - out[#out + 1] = { text, style } - else - out[#out + 1] = text + local text = type(span) == "string" and span or (span[1] or "") + local style = type(span) == "table" and span[2] or nil + text = clip_text(text, byte_budget - used_bytes, width_budget - used_width) + if text ~= "" then + out[#out + 1] = style and { text, style } or text + used_bytes = used_bytes + #text + used_width = used_width + n00n.ui.display_width(text) end - used = used + #text + end + if add_ellipsis then + out[#out + 1] = { ELLIPSIS, "dim" } end return out end -- opts: max_lines (default 80) shown while collapsed, keep "head"|"tail" -- (default "tail"), max_expand_lines (default 2000) kept for expansion, --- max_line_bytes (optional) per-line byte cap applied at render time. +-- max_line_bytes and max_width (optional) cap each complete rendered row, +-- and hide_collapsed (default false) reveals body lines only after a click. function ToolView.new(buf, opts) local self = setmetatable({}, ToolView) self.buf = buf @@ -105,6 +111,8 @@ function ToolView.new(buf, opts) self.keep = (opts and opts.keep) or "tail" self.max_expand_lines = (opts and opts.max_expand_lines) or 2000 self.max_line_bytes = (opts and opts.max_line_bytes) or nil + self.max_width = (opts and opts.max_width) or nil + self.hide_collapsed = (opts and opts.hide_collapsed) or false self.header = {} self.ring = {} self.ring_start = 1 @@ -120,7 +128,7 @@ end function ToolView:set_header(lines) local capped = {} for i = 1, #lines do - capped[i] = truncate_line(lines[i], self.max_line_bytes) + capped[i] = truncate_line(lines[i], self.max_line_bytes, self.max_width) end self.header = capped self:flush() @@ -138,7 +146,7 @@ function ToolView:clear() end function ToolView:append(line) - line = truncate_line(line, self.max_line_bytes) + line = truncate_line(line, self.max_line_bytes, self.max_width) local all_idx if #self.all_lines < self.max_expand_lines then self.all_lines[#self.all_lines + 1] = line @@ -240,7 +248,7 @@ function ToolView:flush() if self.all_skipped > 0 then lines[#lines + 1] = { { self.all_skipped .. " lines omitted", "dim" } } end - else + elseif not self.hide_collapsed then local hidden = self.skipped local notice = hidden >= 2 and { { "... (" .. hidden .. " lines) (click to expand)", "dim" } } or hidden == 1 and self.all_lines[self.keep == "tail" and 1 or self.ring_count + 1] @@ -262,11 +270,14 @@ function ToolView:flush() end end + for i = 1, #lines do + lines[i] = truncate_line(lines[i], self.max_line_bytes, self.max_width) + end self.buf:set_lines(lines) end function ToolView:update_line(all_idx, line) - line = truncate_line(line, self.max_line_bytes) + line = truncate_line(line, self.max_line_bytes, self.max_width) self.all_lines[all_idx] = line for ri = 1, self.ring_count do if self.ring_map[ri] == all_idx then diff --git a/plugins/lib/tests/spec.lua b/plugins/lib/tests/spec.lua index 3ce7e12d7..9bdd523e0 100644 --- a/plugins/lib/tests/spec.lua +++ b/plugins/lib/tests/spec.lua @@ -1,3 +1,4 @@ +local ActivityPreview = require("n00n.activity_preview") local truncate = require("n00n.truncate") local ToolView = require("n00n.tool_view") @@ -160,6 +161,54 @@ case("tool_view_toggle_twice_collapses_back", function() eq(buf.lines[2], "line8") end) +case("tool_view_hide_collapsed_reveals_body_on_toggle", function() + local buf = mock_buf() + local view = ToolView.new(buf, { max_lines = 5, hide_collapsed = true }) + view:set_header({ "activity" }) + view:append("bash - cargo test") + eq(#buf.lines, 1) + eq(buf.lines[1], "activity") + + view:toggle() + eq(#buf.lines, 2) + eq(buf.lines[2], "bash - cargo test") + + view:toggle() + eq(#buf.lines, 1) +end) + +local function line_text(line) + if type(line) == "string" then + return line + end + local out = {} + for _, span in ipairs(line) do + out[#out + 1] = type(span) == "string" and span or (span[1] or "") + end + return table.concat(out) +end + +case("tool_view_caps_complete_rows_by_width_and_bytes", function() + local buf = mock_buf() + local view = ToolView.new(buf, { max_lines = 1, max_width = 8, max_line_bytes = 10 }) + view:set_header({ { { "界界界界界", "bold" }, { " credential", "dim" } } }) + view:append("first") + view:append("second") + + for _, line in ipairs(buf.lines) do + local text = line_text(line) + assert(#text <= 10, "row must honor byte cap: " .. #text) + assert(n00n.ui.display_width(text) <= 8, "row must honor display-width cap") + end +end) + +case("tool_view_tiny_byte_cap_never_overflows_for_ellipsis", function() + local buf = mock_buf() + local view = ToolView.new(buf, { max_lines = 1, max_line_bytes = 2 }) + view:set_header({ "abcdef" }) + assert(#line_text(buf.lines[1]) <= 2, "ellipsis must fit inside byte cap") +end) + case("tool_view_toggle_head_mode_expands", function() local buf = mock_buf() local view = ToolView.new(buf, { max_lines = 2, keep = "head" }) @@ -261,9 +310,9 @@ case("tool_view_max_line_bytes_truncates_string_line", function() local buf = mock_buf() local view = ToolView.new(buf, { max_lines = 3, keep = "tail", max_line_bytes = 10 }) view:append(string.rep("a", 20)) - eq(#buf.lines[1], 13) + eq(#buf.lines[1], 10) assert(buf.lines[1]:find("…"), "truncated line should end with ellipsis") - eq(buf.lines[1]:sub(1, 10), string.rep("a", 10)) + eq(buf.lines[1]:sub(1, 7), string.rep("a", 7)) end) case("tool_view_max_line_bytes_truncates_span_line", function() @@ -273,14 +322,15 @@ case("tool_view_max_line_bytes_truncates_span_line", function() eq(buf.lines[1][1][1], "hello") eq(buf.lines[1][1][2], "dim") eq(buf.lines[1][3][2], "error") - assert(buf.lines[1][3][1]:find("…"), "span should be truncated with ellipsis") + eq(buf.lines[1][4][1], "…") + assert(#line_text(buf.lines[1]) <= 12, "complete span row must fit byte limit") end) case("tool_view_max_line_bytes_utf8_safe", function() local buf = mock_buf() local view = ToolView.new(buf, { max_lines = 3, keep = "tail", max_line_bytes = 10 }) view:append("éééééééééé") - eq(buf.lines[1], string.rep("é", 5) .. "…") + eq(buf.lines[1], string.rep("é", 3) .. "…") end) case("tool_view_max_line_bytes_default_off", function() @@ -1319,6 +1369,81 @@ case("route_tier_blank_prompt", function() eq(route_tier.route_tier(nil), "medium") end) +case("activity_preview_publishes_immediately_and_keeps_five_sessions", function() + local old_buf = n00n.ui.buf + n00n.ui.buf = function() + local buf = mock_buf() + function buf:on() end + return buf + end + local live_buf + local ctx = { + live_buf = function(_, buf) + live_buf = buf + end, + } + local preview, err = ActivityPreview.new(ctx, "team", { session_rows = true }) + eq(err, nil) + eq(live_buf, preview.view.buf, "preview must publish before any prompt") + + local old_activity = { activities = { { id = "old", tool = "read", status = "success" } } } + preview:update(old_activity, "role1", "session1") + eq(preview.rows[1].label, "read", "message-free activity must show only tool and status") + for i = 1, 6 do + preview:set_row("session" .. i, "role" .. i, nil, "success") + end + eq(#preview.rows, 5) + eq(preview.rows[1].label, "role2") + eq(preview.rows[5].label, "role6") + preview:update(old_activity, "role1", "session1") + eq(preview.rows[1].label, "role2", "evicted historical activity must not displace newer sessions") + n00n.ui.buf = old_buf +end) + +case("activity_preview_repeated_prompts_keep_order_and_update_status_in_place", function() + local old_buf = n00n.ui.buf + n00n.ui.buf = function() + local buf = mock_buf() + function buf:on() end + return buf + end + local preview = ActivityPreview.new({ live_buf = function() end }, "team", { session_rows = true }) + local session = { turn = 0 } + function session:prompt() + self.turn = self.turn + 1 + return { text = "done" } + end + function session:get_progress() + return { + turn_id = self.turn, + done = true, + activities = { { id = "tool-" .. self.turn, tool = "read", status = "running" } }, + } + end + + local _, first_err = preview:prompt(session, "first", "worker") + local _, second_err = preview:prompt(session, "second", "worker") + eq(first_err, nil) + eq(second_err, nil) + eq(#preview.rows, 4) + eq(preview.rows[1].key, "worker#1") + eq(preview.rows[2].key, tostring(session) .. "/tool-1") + eq(preview.rows[3].key, "worker#2") + eq(preview.rows[4].key, tostring(session) .. "/tool-2") + eq(preview.rows[1].status, "success") + eq(preview.rows[3].status, "success") + + preview:update({ activities = { { id = "tool-2", tool = "read", status = "success" } } }, "worker", tostring(session)) + eq(#preview.rows, 4, "completion must update the existing activity row") + eq(preview.rows[4].status, "success") + local tracked = 0 + for _ in pairs(preview.activity_ids[tostring(session)]) do + tracked = tracked + 1 + end + eq(tracked, 1, "per-session activity tracking must stay snapshot-bounded") + n00n.ui.buf = old_buf +end) + if #failures > 0 then error(#failures .. " case(s) failed:\n\n" .. table.concat(failures, "\n\n")) end diff --git a/plugins/task/init.lua b/plugins/task/init.lua index 91b20e350..d05cc3295 100644 --- a/plugins/task/init.lua +++ b/plugins/task/init.lua @@ -5,6 +5,7 @@ -- primitives only (`n00n.agent.session`, `n00n.json.schema_validator`, -- `n00n.async.semaphore`). +local ActivityPreview = require("n00n.activity_preview") local ToolView = require("n00n.tool_view") local route_tier = require("n00n.route_tier").route_tier @@ -116,41 +117,6 @@ local function bounded_errors(errors) return table.concat(out, "\n") end -local function make_preview(ctx, description) - local tol = ctx:tool_output_lines() - local max_preview = (tol and tol.task) or DEFAULT_OUTPUT_LINES - local view = ToolView.new(n00n.ui.buf(), { max_lines = max_preview, keep = "tail" }) - local last_completed = 0 - - local function update(progress) - if progress.completed_count > last_completed then - local new_count = progress.completed_count - last_completed - local recent = progress.recent_tools - local start = new_count <= #recent and (#recent - new_count + 1) or 1 - for i = start, #recent do - view:append({ { "✓ " .. recent[i], "dim" } }) - end - last_completed = progress.completed_count - end - - local elapsed = math.floor(progress.elapsed_ms / 1000) - local elapsed_str = n00n.ui.humantime(elapsed) - local header = { { description .. " · " .. elapsed_str, "bold" } } - if progress.current_tool then - header[#header + 1] = { { "▸ " .. progress.current_tool, "bold" } } - elseif not progress.done then - header[#header + 1] = { { "Starting...", "dim" } } - end - view:set_header(header) - end - - view.buf:on("click", function() - view:toggle() - end) - - return { buf = view.buf, update = update } -end - local function handler(input, ctx) if input.background then local forwarded = {} @@ -255,7 +221,11 @@ local function handler(input, ctx) } end - local preview = make_preview(ctx, input.description or "task") + local task_description = input.description or "task" + local preview, preview_err = ActivityPreview.new(ctx, task_description) + if not preview then + return { llm_output = "failed to publish task preview: " .. tostring(preview_err), is_error = true } + end local permit local ok, out = pcall(function() @@ -278,11 +248,11 @@ local function handler(input, ctx) if validator then message = message .. STRUCTURED_OUTPUT_PROMPT_SUFFIX end - local result, err = sess:prompt(message) + local result, err = preview:prompt(sess, message, task_description) local retries = 0 while not err and validator and not captured and retries < MAX_STRUCTURED_RETRIES do retries = retries + 1 - result, err = sess:prompt(NUDGE_MISSING) + result, err = preview:prompt(sess, NUDGE_MISSING, task_description) end if err then return { llm_output = "sub-agent error: " .. err, is_error = true } @@ -305,36 +275,19 @@ local function handler(input, ctx) return { llm_output = output, format = "markdown" } end - local function do_poll() - while true do - local progress = sess:get_progress() - if not progress then - return - end - preview:update(progress) - if progress.done then - return - end - end - end - - local results = n00n.async.gather({ do_prompt, do_poll }) + local result = do_prompt() sess:close() - local prompt_res = results[1] - if not prompt_res.ok then - error(prompt_res.err, 0) - end - return prompt_res.value + return result end) if permit then permit:release() end if not ok then - return { llm_output = "task failed: " .. tostring(out), is_error = true, body = preview.buf } + return { llm_output = "task failed: " .. tostring(out), is_error = true, body = preview.view.buf } end return { llm_output = out.llm_output, - body = preview.buf, + body = preview.view.buf, is_error = out.is_error, format = out.format, } diff --git a/plugins/team/init.lua b/plugins/team/init.lua index d58112627..995446c19 100644 --- a/plugins/team/init.lua +++ b/plugins/team/init.lua @@ -3,6 +3,7 @@ -- (product_manager, planner, developer, tester, reviewer); each runs as its own -- subagent on a cost-aware model tier. Built entirely on n00n.agent.* and the -- existing provider/model-tier machinery — no core changes. +local ActivityPreview = require("n00n.activity_preview") local memory = require("mem") local retrieve = require("retrieve") local roles = require("roles") @@ -204,9 +205,9 @@ local function run_supervisor(ctx, goal, opts) return nil, sess_err end - local res, rerr = sess:prompt(plan_prompt(goal)) + local _, rerr = opts._preview:prompt(sess, plan_prompt(goal), "supervisor") if not rerr and not captured then - res, rerr = sess:prompt(NUDGE) + _, rerr = opts._preview:prompt(sess, NUDGE, "supervisor") end sess:close() if rerr then @@ -254,6 +255,7 @@ local function run_step(ctx, step, goal, input, relay_k, prior_results) auto_tier = input.auto_tier, thinking = input.thinking, budget = input._agent_budget, + preview = input._preview, } return roles.run(ctx, step.role, step_prompt, role_opts) end @@ -279,6 +281,7 @@ local function run_autonomous(ctx, goal, input, steps, relay_k) model = input.model, thinking = input.thinking, budget = input._agent_budget, + preview = input._preview, }) if not verdict.accepted then results[#results + 1] = string.format( @@ -319,7 +322,7 @@ end local finish_run -local function handler(input, ctx) +local function run_team(input, ctx) if input.background then local forwarded = {} for key, value in pairs(input) do @@ -387,6 +390,7 @@ local function handler(input, ctx) budget = input._agent_budget, thinking = input.thinking, quorum = input.quorum, + preview = input._preview, }) if not out.ok then return { llm_output = "swarm failed: " .. (out.error or "unknown"), is_error = true } @@ -405,6 +409,23 @@ local function handler(input, ctx) return finish_run(ctx, input, results, total_cost, #results, "steps", slug, failures) end +local function handler(input, ctx) + if input.background then + return run_team(input, ctx) + end + local preview, preview_err = ActivityPreview.new(ctx, "team: " .. (input.goal or "team"), { session_rows = true }) + if not preview then + return { llm_output = "failed to publish team preview: " .. tostring(preview_err), is_error = true } + end + input._preview = preview + local ok, result = pcall(run_team, input, ctx) + if not ok then + return { llm_output = "team failed: " .. tostring(result), is_error = true, body = preview.view.buf } + end + result.body = preview.view.buf + return result +end + finish_run = function(ctx, input, results, total_cost, completed, unit, slug, failures) local report = table.concat(results, "\n\n") local failed = failures or 0 diff --git a/plugins/team/quorum.lua b/plugins/team/quorum.lua index 4eb5fa452..086e1c116 100644 --- a/plugins/team/quorum.lua +++ b/plugins/team/quorum.lua @@ -53,7 +53,12 @@ local function run_one(ctx, v, artifact, opts) return { approved = false, issues = { serr }, model = v.tier } end local prompt = "Review this artifact for " .. v.prompt .. ". " .. artifact - local res, rerr = sess:prompt(prompt) + local res, rerr + if opts.preview then + res, rerr = opts.preview:prompt(sess, prompt, "quorum-" .. v.prompt) + else + res, rerr = sess:prompt(prompt) + end sess:close() if rerr or not res or not res.text then return { approved = false, issues = { rerr or "no output" }, model = v.tier } diff --git a/plugins/team/roles.lua b/plugins/team/roles.lua index 1ec5fe6df..e3b1056c6 100644 --- a/plugins/team/roles.lua +++ b/plugins/team/roles.lua @@ -68,7 +68,12 @@ function M.run(ctx, role, prompt, opts) return { ok = false, error = serr } end - local res, rerr = sess:prompt(prompt) + local res, rerr + if opts.preview then + res, rerr = opts.preview:prompt(sess, prompt, opts.activity_label or role) + else + res, rerr = sess:prompt(prompt) + end sess:close() if rerr then return { ok = false, error = rerr } diff --git a/plugins/team/swarm.lua b/plugins/team/swarm.lua index 6ead0e7d5..d9ede327f 100644 --- a/plugins/team/swarm.lua +++ b/plugins/team/swarm.lua @@ -112,12 +112,14 @@ local function run_agent(ctx, agent, event, opts) prompt = prompt .. "\n\nRelevant context:\n" .. block end end - return roles.run( - ctx, - agent.slot, - prompt, - { model = opts.model, model_tier = ROLE_TIER[agent.slot], thinking = opts.thinking, budget = opts.budget } - ) + return roles.run(ctx, agent.slot, prompt, { + model = opts.model, + model_tier = ROLE_TIER[agent.slot], + thinking = opts.thinking, + budget = opts.budget, + preview = opts.preview, + activity_label = "swarm-" .. agent.id, + }) end -- Validate a round's contributions with the EBFT quorum; return accepted issues. @@ -131,6 +133,7 @@ local function validate_round(ctx, workers_output, opts) model = opts.model, thinking = opts.thinking, budget = opts.budget, + preview = opts.preview, }) end diff --git a/plugins/team/tests/spec.lua b/plugins/team/tests/spec.lua index fd99102e3..2934cca7d 100644 --- a/plugins/team/tests/spec.lua +++ b/plugins/team/tests/spec.lua @@ -97,7 +97,7 @@ case("roles_run_with_custom_opts", function() return {}, nil end - local session_name = nil + local session_name, activity_label n00n.agent.session = function(ctx, opts) session_name = opts.name return { @@ -112,8 +112,14 @@ case("roles_run_with_custom_opts", function() return 0.05, nil end + local preview = { + prompt = function(_, sess, prompt, label) + activity_label = label + return sess:prompt(prompt) + end, + } local dummy_ctx = {} - local res = roles.run(dummy_ctx, "developer", "implement helper", { model_tier = "medium" }) + local res = roles.run(dummy_ctx, "developer", "implement helper", { model_tier = "medium", preview = preview }) n00n.agent.resolve_model = old_resolve n00n.agent.tools = old_tools @@ -123,6 +129,7 @@ case("roles_run_with_custom_opts", function() assert(res.ok == true, "roles.run should succeed") assert(resolved_tier == "medium", "should use custom model tier") assert(session_name == "developer", "session name should match role") + assert(activity_label == "developer", "role session must publish through the shared preview") assert(res.text == "implemented!", "returned text should match mock") assert(res.cost == 0.05, "cost should match mock") end) @@ -290,6 +297,23 @@ case("quorum_validators_receive_no_tools", function() assert(result.accepted, "tool-less validator should approve") end) +case("quorum_routes_each_validator_through_preview", function() + local quorum = require("quorum") + local restore = stub_agent(true) + local labels = {} + local preview = { + prompt = function(_, sess, prompt, label) + labels[#labels + 1] = label + return sess:prompt(prompt) + end, + } + local verdict = quorum.validate({}, "artifact", { n = 3, preview = preview }) + restore() + assert(verdict.accepted, "preview must not change quorum result") + assert(#labels == 3, "every quorum session must reach the preview") + assert(labels[1] == "quorum-security", "validator label must identify quorum role") +end) + case("quorum_all_reject_rejected", function() local quorum = require("quorum") local restore = stub_agent(false) @@ -359,9 +383,20 @@ case("swarm_dry_run_terminates_within_max_rounds", function() } end + local activity_labels = {} + local preview = { + prompt = function(_, sess, prompt, label) + activity_labels[#activity_labels + 1] = label + return sess:prompt(prompt) + end, + } local dummy_ctx = {} local ok, out = pcall(function() - return swarm.run(dummy_ctx, "add a retry helper and write tests", { relay_k = 2, max_rounds = 4 }) + return swarm.run(dummy_ctx, "add a retry helper and write tests", { + relay_k = 2, + max_rounds = 4, + preview = preview, + }) end) n00n.env.state_dir = old_env @@ -375,6 +410,9 @@ case("swarm_dry_run_terminates_within_max_rounds", function() assert(ok, "swarm.run should not error: " .. tostring(out)) assert(out.ok == true, "swarm must report ok") assert(out.text ~= nil and out.text ~= "", "swarm must emit a report") + assert(activity_labels[1] == "swarm-explorer", "swarm explorer label must identify its agent") + assert(activity_labels[2] == "swarm-worker", "swarm worker label must identify its agent") + assert(activity_labels[3] == "quorum-security", "swarm quorum must retain validator labels") -- One accepted round = 2 explorer/worker + 3 quorum = 5 sessions * 0.01. -- Bounded by max_rounds means cost stays at one round, not 4. assert(out.cost <= 0.06, "swarm must terminate within max_rounds (cost " .. tostring(out.cost) .. ")") diff --git a/site/docs/content/configuration/_index.md b/site/docs/content/configuration/_index.md index 6d0fbf84c..2203b95c8 100644 --- a/site/docs/content/configuration/_index.md +++ b/site/docs/content/configuration/_index.md @@ -70,7 +70,7 @@ All fields are optional. Typos in field names cause an error right away. | `mouse_scroll_lines` | u32 | `3` | 1 | Lines per mouse wheel scroll | | `max_input_lines` | u32 | `20` | 1 | Maximum visible input lines | | `show_thinking` | bool | `true` | - | When true (default), show full model reasoning live and persisted. When false, hide reasoning behind an indicator (thinking> ...) with a click-to-expand hint, both while thinking and after it completes | -| `mascot` | bool | `false` | - | Show the n00n mascot on the idle splash screen (opt-in) | +| `mascot` | bool | `false` | - | Show the n00n mascot on the idle splash screen | ### `ui.tool_output_lines` @@ -84,6 +84,7 @@ How many lines of output to show per tool in the UI. All values are `usize` with | `workflow` | 8 | | `index` | 3 | | `grep` | 3 | +| `explore` | 3 | | `read` | 3 | | `write` | 7 | | `web` | 3 | @@ -150,6 +151,13 @@ n00n.setup({ | `ruff_fix` | boolean | `true` | - | Run Ruff --fix --unsafe-fixes and formatting before execution when Ruff is available. | | `timeout_secs` | integer | `30` | 5 | Stop the script after this many seconds. A call's `timeout` param overrides it. | +### `plugins.codegraph` + +| Field | Type | Default | Min | Description | +|-------|------|---------|-----|-------------| +| `max_output_bytes` | integer | - | - | Override `agent.max_output_bytes` for this tool. | +| `max_output_lines` | integer | - | - | Override `agent.max_output_lines` for this tool. | + ### `plugins.edit` | Field | Type | Default | Min | Description | @@ -199,7 +207,7 @@ n00n.setup({ | Field | Type | Default | Min | Description | |-------|------|---------|-----|-------------| | `auto_tier` | boolean | `false` | - | Route each subagent's model tier from its prompt (opt-in, off by default). | -| `max_concurrent` | integer | `8` | 1 | Max concurrently running subagents. | +| `max_concurrent` | integer | `4` | 1 | Concurrent subagents (hard max 8). | ### `plugins.webfetch` @@ -221,8 +229,9 @@ n00n.setup({ | Field | Type | Default | Min | Description | |-------|------|---------|-----|-------------| -| `max_concurrent_agents` | integer | `8` | 1 | Max subagents one parallel()/pipeline() call runs at once. | -| `max_concurrent_workflows` | integer | `4` | 1 | Max concurrently running workflows. | +| `max_agents_per_run` | integer | `24` | 1 | Agent-call budget per workflow (hard max 32). | +| `max_concurrent_agents` | integer | `4` | 1 | Concurrency per parallel()/pipeline() (hard max 8). | +| `max_concurrent_workflows` | integer | `2` | 1 | Concurrent workflows (hard max 4). | | `timeout_secs` | integer | `600` | 1 | Hard deadline for one workflow run (cancels pure-Lua runaway loops via the VM watchdog). | ## Validation diff --git a/site/docs/content/keybindings/_index.md b/site/docs/content/keybindings/_index.md index e8c9d1687..e9b8724ef 100644 --- a/site/docs/content/keybindings/_index.md +++ b/site/docs/content/keybindings/_index.md @@ -20,6 +20,7 @@ On macOS, some bindings use Option or Fn keys instead (run `/help` for exact key | `Ctrl+S` | File picker | | `Ctrl+O` | Open plan in editor | | `Ctrl+T` | Toggle plan panel | +| `Alt+T` | Toggle transcript details | | `Ctrl+X` | Open tasks | ## Editing diff --git a/site/docs/content/lua-api/_index.md b/site/docs/content/lua-api/_index.md index c7bd2ca71..47cf6701c 100644 --- a/site/docs/content/lua-api/_index.md +++ b/site/docs/content/lua-api/_index.md @@ -86,6 +86,7 @@ a string belongs. | [`n00n.ui.Win`](#n00n-ui-Win) | Handle to a floating or split window. | | [`n00n.ui.Buf`](#n00n-ui-Buf) | A content buffer that holds styled lines of text. | | [`n00n.uv`](#n00n-uv) | System and environment utilities, modelled after `vim.uv`. | +| [`n00n.arbor`](#n00n-arbor) | Graph-based code analysis via Arbor CLI. | | [`n00n.workflow`](#n00n-workflow) | Sandboxed workflow script compilation. | | [`n00n.yaml`](#n00n-yaml) | YAML encoding and decoding. | @@ -908,12 +909,25 @@ Returns a table with: `elapsed_ms` (integer): time since the session was created. `current_tool` (string?): name of the tool currently running, if any. `recent_tools` (table): names of the last few finished tools, oldest first. + `activities` (table): up to five safe rendered tool summaries, oldest first. `completed_count` (integer): total number of finished tools so far. - `done` (bool): true once the prompt has completed. + `turn_id` (integer): increases before each `prompt` call. + `done` (bool): true once the current prompt call has completed. The call returns at most every `PROGRESS_TIMEOUT_MS` milliseconds, or immediately when a tool starts or finishes. +--- + +### `Session:cancel()` {#Session-cancel} + +```lua +Session:cancel() +``` + +Cancel the current turn in this session without closing it. The agent will +stop at the next cancellation point and return an error from `:prompt()`. + ## n00n.async {#n00n-async} @@ -2213,6 +2227,48 @@ Decode a TOON string back into a Lua value. Inverse of `to_toon`. local t, err = n00n.json.from_toon(s) ``` +--- + +### `n00n.json.tooned()` {#n00n-json-tooned} + +```lua +n00n.json.tooned({value}) +``` + +Lossless JSON/TOON passthrough. Encodes the value as JSON and TOON and +returns whichever representation is smaller. If TOON does not shrink the +payload, the original JSON string is returned unchanged. + +**Parameters:** + +- `{value}` (`any`) Lua value to encode. + +**Returns:** (`string?`, `string?`) Encoded string (JSON or TOON) and its format ("json" or "toon"), or nil plus an error. + +**Example:** + +```lua +local s, fmt = n00n.json.tooned({ users = { { id = 1, name = "Alice" } } }) +``` + +--- + +### `n00n.json.toon_stats()` {#n00n-json-toon_stats} + +```lua +n00n.json.toon_stats() +``` + +Return historical TOON passthrough statistics. + +**Returns:** (`table?`, `string?`) Stats table with calls, json_bytes, toon_bytes, toon_wins, saved_bytes, or nil plus an error. + +**Example:** + +```lua +local stats, err = n00n.json.toon_stats() +``` + ## n00n.json.SchemaValidator {#n00n-json-SchemaValidator} @@ -4119,6 +4175,30 @@ end --- +### `n00n.ui.pick_model()` {#n00n-ui-pick_model} + +```lua +n00n.ui.pick_model({current?}) +``` + +Opens n00n's native model discovery picker and waits for a selection. +Returns nil when the picker is cancelled or the UI is unavailable. + +**Parameters:** + +- `{current?}` (`string?`) Model spec to preselect. Omit for no preselection. + +**Returns:** (`string?`) Selected model spec, or nil when cancelled. + +**Example:** + +```lua +local model = n00n.ui.pick_model(current_model) +if model then current_model = model end +``` + +--- + ### `n00n.ui.open_win()` {#n00n-ui-open_win} ```lua @@ -4683,6 +4763,151 @@ local editor = n00n.uv.os_getenv("EDITOR") or "vi" ``` +## n00n.arbor {#n00n-arbor} + +Graph-based code analysis via Arbor CLI. Wraps `arbor callers`, `arbor callees`, `arbor map`, `arbor diff`, `arbor query`, and `arbor status`. Each method shells out to the `arbor` binary (Anandb71/arbor, `cargo install arbor-graph-cli`) and parses its JSON output into Lua tables. + +--- + +### `n00n.arbor.check_binary()` {#n00n-arbor-check_binary} + +```lua +n00n.arbor.check_binary() +``` + +Check that the `arbor` CLI is installed and working. + +**Returns:** (`nil|string`) nil on success, or an error message string. + +--- + +### `n00n.arbor.available()` {#n00n-arbor-available} + +```lua +n00n.arbor.available() +``` + +Returns true if the `arbor` CLI is on PATH. + +**Returns:** (`boolean`) true when arbor is available. + +--- + +### `n00n.arbor.callers()` {#n00n-arbor-callers} + +```lua +n00n.arbor.callers({symbol}, {project}) +``` + +Show who calls a symbol. + +**Parameters:** + +- `{symbol}` (`string`) Symbol name (function, class, etc.) +- `{project}` (`string`) Path to the project root. + +**Returns:** (`table`) Array of caller objects with `name`, `path`, `kind`, `line` fields. + +--- + +### `n00n.arbor.callees()` {#n00n-arbor-callees} + +```lua +n00n.arbor.callees({symbol}, {project}) +``` + +Show what a symbol calls. + +**Parameters:** + +- `{symbol}` (`string`) Symbol name. +- `{project}` (`string`) Path to the project root. + +**Returns:** (`table`) Array of callee objects with `name`, `path`, `kind`, `line` fields. + +--- + +### `n00n.arbor.map()` {#n00n-arbor-map} + +```lua +n00n.arbor.map({project}, {token_budget?}) +``` + +Ranked project skeleton with symbols. + +**Parameters:** + +- `{project}` (`string`) Path to the project root. +- `{token_budget}` (`integer`) Optional token budget (default 1024). + +**Returns:** (`table`) Array of map entries with `file`, `symbols` (each with `name`, `kind`, `line`, `centrality`, `callers`). + +--- + +### `n00n.arbor.diff()` {#n00n-arbor-diff} + +```lua +n00n.arbor.diff({project}) +``` + +Blast radius of unpushed git changes. + +**Parameters:** + +- `{project}` (`string`) Path to the project root. + +**Returns:** (`table`) Impact object with `direct_callers`, `indirect_callers`, `blast_radius_nodes`, `api_entrypoints_affected`, `files_likely_require_updates`. + +--- + +### `n00n.arbor.query()` {#n00n-arbor-query} + +```lua +n00n.arbor.query({query}, {project}) +``` + +Free-text search of the code graph. + +**Parameters:** + +- `{query}` (`string`) Search query text. +- `{project}` (`string`) Path to the project root. + +**Returns:** (`string`) Raw query results as text. + +--- + +### `n00n.arbor.status()` {#n00n-arbor-status} + +```lua +n00n.arbor.status({project}) +``` + +Show Arbor index status for a project. + +**Parameters:** + +- `{project}` (`string`) Path to the project root. + +**Returns:** (`string`) Status text. + +--- + +### `n00n.arbor.ensure_indexed()` {#n00n-arbor-ensure_indexed} + +```lua +n00n.arbor.ensure_indexed({project}) +``` + +Run `arbor index` if the project is not yet indexed. + +**Parameters:** + +- `{project}` (`string`) Path to the project root. + +**Returns:** (`nil`) nil on success, or error on failure. + + ## n00n.workflow {#n00n-workflow} Sandboxed workflow script compilation. @@ -4812,6 +5037,16 @@ print(t.name) -- n00n These ship inside n00n; `require` them from any plugin. Small modules are shown as full source, larger ones as their public interface. +### `require("n00n.activity_preview")` + +```lua +function ActivityPreview.new(ctx, description, opts) +function ActivityPreview:render() +function ActivityPreview:set_row(key, label, message, status) +function ActivityPreview:update(progress, label, session_key) +function ActivityPreview:prompt(sess, message, label) +``` + ### `require("n00n.color")` ```lua @@ -5016,7 +5251,8 @@ function TextInput:render(prefix, prefix_width, width) -- opts: max_lines (default 80) shown while collapsed, keep "head"|"tail" -- (default "tail"), max_expand_lines (default 2000) kept for expansion, --- max_line_bytes (optional) per-line byte cap applied at render time. +-- max_line_bytes and max_width (optional) cap each complete rendered row, +-- and hide_collapsed (default false) reveals body lines only after a click. function ToolView.new(buf, opts) function ToolView:set_header(lines) function ToolView:clear() diff --git a/site/docs/content/tools/_index.md b/site/docs/content/tools/_index.md index 7e32b45c2..c113b9fb1 100644 --- a/site/docs/content/tools/_index.md +++ b/site/docs/content/tools/_index.md @@ -7,7 +7,7 @@ group = "Reference" # Tools -n00n ships with 23 built-in tools. This is the full reference. +n00n ships with 25 built-in tools. This is the full reference. ## File Operations @@ -122,6 +122,26 @@ View an image file (png, jpeg, gif, webp) so you can actually see it; it is retu |-----------|------|----------|-------------| | `path` | string | yes | Path to the image file | +### `codegraph` *(lua plugin)* + +Query a pre-indexed semantic codegraph for cross-file structural analysis. Returns verbatim source code grouped by file, plus a dependency impact "blast radius" summary with caller counts and test coverage info. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `projectPath` | string | no | Absolute path to the project (defaults to current workspace) | +| `query` | string | yes | Natural language question or symbol/file names to explore (e.g. 'AuthService login', 'GraphTraverser BFS impact') | + +### `arbor` *(lua plugin)* + +Graph-based code analysis using Arbor. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `command` | string | yes | | +| `token_budget` | integer | no | | +| `project` | string | no | | +| `symbol` | string | no | | + ## Execution & Control ### `batch` *(lua plugin)* @@ -167,16 +187,18 @@ Control background agents started by task or team. ### `team` *(lua plugin)* -Launch an agent team. A supervisor decomposes an SDLC goal into role agents and runs each as its own subagent on a cost-aware model tier: +Run a bounded ALMAS team for an SDLC goal. Roles cover scope, planning, implementation, testing, and review on cost-aware tiers. +supervised returns a plan; autonomous executes it with optional validator quorum; swarm runs bounded explorer/worker/validator rounds with an information-bottleneck fan-out gate. model overrides tiers; auto_tier routes by task. use_retrieval grounds work, compact TOON-encodes that context, and background returns an agent_id for agent_control. Default/hard budgets are 16/24 agents and 4 concurrent. | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| +| `max_agents` | integer | no | 16, hard maximum 24 | Total team agent-call budget. | | `background` | boolean | no | | Start the team in a separate background session and return its agent_id immediately. | | `compact` | boolean | no | | Encode retrieved context as TOON (token-saving, opt-in). | | `model_tier` | string | no | | Supervisor/model tier (weak/medium/strong). Defaults to strong when model is omitted. | | `quorum` | boolean | no | | Require validator quorum for autonomous validation and swarm acceptance. | | `max_steps` | integer | no | 6, maximum 8 | Maximum supervisor plan steps to execute. | -| `max_concurrent` | integer | no | 8 | Swarm mode only: max concurrent subagents per round. | +| `max_concurrent` | integer | no | and maximum 4 | Swarm concurrency. | | `ibn_gate` | boolean | no | | Use the information-bottleneck fan-out gate in swarm mode. | | `max_rounds` | integer | no | 2, maximum 4 | Swarm mode only: max coordination rounds. | | `goal` | string | yes | | High-level SDLC goal, e.g. 'Add a retry helper and cover it with tests.' | @@ -188,12 +210,13 @@ Launch an agent team. A supervisor decomposes an SDLC goal into role agents and ### `task` *(lua plugin)* -Launch an autonomous subagent to perform tasks independently. Best combined with batch. +Launch one isolated agent; combine independent calls with batch. +research (default) is read-only; general can edit. Each call starts fresh, so include context and ask for concise file:line results. Summarize returned results for the user. auto_tier is opt-in. background returns an agent_id for agent_control. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `description` | string | yes | Short (3-5 words) description of the task | -| `model_tier` | string | no | Model tier (optional, omit to use current model, capped at current tier):
- "strong" (e.g. Opus): Deep reasoning, complex architecture, subtle bugs, most critical sections. ~5x cost of medium.
- "medium" (e.g. Sonnet): Balanced. Refactors, features, multi-file changes.
- "weak" (e.g. Haiku): Fast/cheap. Search, summarize, boilerplate, simple edits. | +| `model_tier` | string | no | Optional capped tier: "weak" for simple work, "medium" for normal changes, or "strong" for complex/critical work. | | `auto_tier` | boolean | no | Pick model_tier from the prompt automatically (opt-in). Overrides model_tier when set. | | `background` | boolean | no | Start this task in a separate background session and return its agent_id immediately. | | `model` | string | no | Exact model spec (optional, e.g. openai/gpt-5.6-luna). Overrides model_tier. | @@ -204,7 +227,7 @@ Launch an autonomous subagent to perform tasks independently. Best combined with ### `workflow` *(lua plugin)* -Run a workflow script that orchestrates many subagents at scale. +Run a bounded, sandboxed Lua workflow for multi-stage agent orchestration. | Parameter | Type | Required | Description | |-----------|------|----------|-------------|