From 7f9c89398a28be641c1494d6c8d6cde4d5f75dae Mon Sep 17 00:00:00 2001 From: Aris Setiawan Date: Tue, 24 Mar 2026 16:55:30 +0700 Subject: [PATCH 01/11] chore: update CI and release workflows to support multiple branches and enhanced release conditions - Modified CI workflow to trigger on pushes to 'dev' and all branches using wildcard. - Enhanced release workflow to include checks for PR merges and tag pushes, ensuring proper release conditions. - Added new jobs for release checks and CI validation before building and releasing artifacts. - Updated dependencies and installation steps for improved compatibility. --- .github/workflows/ci.yml | 5 +-- .github/workflows/release.yml | 69 ++++++++++++++++++++++++++++++++--- 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c420e45..ebf37e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,16 +2,15 @@ name: CI on: push: - branches: [main] + branches: [main, dev, "**"] pull_request: - branches: [main] + branches: [main, dev, "**"] permissions: contents: read env: CARGO_TERM_COLOR: always - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: check: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 25e4d0a..babbefb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,17 +4,74 @@ on: push: branches: [main] tags: ["v*"] + pull_request: + branches: [main] +# Only run release jobs when: +# - A PR is merged to main (from dev or any branch) +# - A version tag is pushed (v*) permissions: contents: write env: CARGO_TERM_COLOR: always - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true BINARY_NAME: nca jobs: + # Determine if this is a release-worthy push + should-release: + runs-on: ubuntu-latest + outputs: + value: ${{ steps.check.outputs.should_release }} + steps: + - name: Check release conditions + id: check + run: | + # Release on PR merge to main + if [[ "${{ github.event.pull_request.merged }}" == "true" ]]; then + echo "should_release=true" >> "$GITHUB_OUTPUT" + echo "trigger=PR merge to main" >> "$GITHUB_OUTPUT" + # Release on tag push + elif [[ "${{ startsWith(github.ref, 'refs/tags/v') }}" == "true" ]]; then + echo "should_release=true" >> "$GITHUB_OUTPUT" + echo "trigger=Tag push" >> "$GITHUB_OUTPUT" + else + echo "should_release=false" >> "$GITHUB_OUTPUT" + echo "trigger=Non-release push" >> "$GITHUB_OUTPUT" + fi + + # CI check (required before release) + ci-check: + needs: should-release + if: needs.should-release.outputs.value == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - uses: Swatinem/rust-cache@v2 + + - name: Install OpenSSL + run: | + sudo apt-get update + sudo apt-get install -y libssl-dev pkg-config + + - name: Check formatting + run: cargo fmt --check --all + + - name: Clippy + run: cargo clippy --workspace -- -D warnings + + - name: Test + run: cargo test --workspace + + # Multi-platform build build: + needs: [should-release, ci-check] + if: needs.should-release.outputs.value == 'true' strategy: matrix: include: @@ -63,10 +120,11 @@ jobs: name: ${{ env.BINARY_NAME }}-${{ matrix.target }} path: ${{ env.BINARY_NAME }}-${{ matrix.target }}.${{ matrix.archive }} + # Create GitHub Release (only on tags) release: - needs: build + needs: [should-release, build] + if: needs.should-release.outputs.value == 'true' && startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/v') steps: - uses: actions/checkout@v4 @@ -83,10 +141,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Update install script (only on tags) update-install-script: - needs: release + needs: [should-release, release] + if: needs.should-release.outputs.value == 'true' && startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/v') steps: - uses: actions/checkout@v4 From 8867e86d95bd4bc2ad606890c17faa8dd7e3881f Mon Sep 17 00:00:00 2001 From: Indra Gunanda Date: Tue, 24 Mar 2026 18:28:39 +0700 Subject: [PATCH 02/11] Feat/advanced busy indicator (#26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: add wildcard allow command design spec Design for issue #18 — wildcard allow patterns and Ctrl+U interactive "always allow" shortcut for session-scoped tool approval. * docs: fix spec review issues — correct data flow, JSON-aware extraction Addresses reviewer feedback: - Fix suggest_allow_pattern to handle JSON tool input - Correct TUI approval data flow: app.rs → repl.rs → runner.rs - Add missing files: ipc_pending.rs, runner.rs, repl.rs * docs: add wildcard allow implementation plan 6-task plan covering wildcard matching, session allow list, ApprovalVerdict enum, and Ctrl+U TUI shortcut. * feat: add wildcard_matches function for permission patterns * feat: add extract_meaningful_text and suggest_allow_pattern * feat: add session_allow list and human-readable key matching in check() - Add `session_allow: Vec` field (pub) to ApprovalPolicy - Add `add_session_allow()` method with deduplication - Update `check()` to build both json_key and readable_key (via extract_meaningful_text) - Replace key.contains() with wildcard_matches() for deny and allow checks - Chain session_allow with config.allow in explicitly_allowed check * feat: replace bool approval with ApprovalVerdict enum across all handlers * feat: add Ctrl+U always-allow shortcut with session-scoped wildcard pattern * style: apply cargo fmt across workspace * chore: add cargo-husky pre-commit hooks (fmt, clippy, test) * fix: handle single-word commands in suggest_allow_pattern Pattern for single-token inputs like `ls` now generates `execute_bash:ls*` instead of `execute_bash:ls *` which failed to match because the space was mandatory. Also removes docs/superpowers plan and spec files. * feat: add advanced animated busy indicator (#19) Replace the plain busy/idle text in the status bar with a color-coded animated indicator that reflects real-time agent state: - Green idle, brown thinking, orange streaming, cyan tool, amber approval, red error Derive BusyState from existing events in apply_event and emit BusyStateChanged from the agent core at key transition points. --- crates/cli/src/tui/app.rs | 12 ++-- crates/cli/src/tui/busy_indicator.rs | 101 +++++++++++++++++++++++++++ crates/cli/src/tui/mod.rs | 1 + crates/cli/src/tui/state.rs | 25 ++++++- crates/common/src/event.rs | 37 ++++++++++ crates/core/src/agent.rs | 16 ++++- 6 files changed, 185 insertions(+), 7 deletions(-) create mode 100644 crates/cli/src/tui/busy_indicator.rs diff --git a/crates/cli/src/tui/app.rs b/crates/cli/src/tui/app.rs index 50c724a..9066358 100644 --- a/crates/cli/src/tui/app.rs +++ b/crates/cli/src/tui/app.rs @@ -1446,11 +1446,13 @@ pub fn run_blocking( } let elapsed = g.started.elapsed().as_secs(); - let busy = if g.busy { - Span::styled(" ● busy ", Style::default().fg(theme::WARN)) - } else { - Span::styled(" ○ idle ", Style::default().fg(theme::SUCCESS)) - }; + let indicator_text = crate::tui::busy_indicator::render_indicator( + g.current_busy_state, + g.busy_state_since, + ); + let indicator_color = + crate::tui::busy_indicator::color_for_state(g.current_busy_state); + let busy = Span::styled(indicator_text, Style::default().fg(indicator_color)); let approval_hint = if g.active_approval.is_some() { Span::styled(" !approve ", Style::default().fg(theme::ERROR)) } else { diff --git a/crates/cli/src/tui/busy_indicator.rs b/crates/cli/src/tui/busy_indicator.rs new file mode 100644 index 0000000..bb5d650 --- /dev/null +++ b/crates/cli/src/tui/busy_indicator.rs @@ -0,0 +1,101 @@ +//! Advanced animated busy indicator (Claude-style). + +use nca_common::event::BusyState; +use ratatui::style::Color; +use std::time::Instant; + +/// Animation frames for different busy states. +const THINKING_FRAMES: &[&str] = &["◐", "◓", "◑", "◒"]; +const STREAMING_FRAMES: &[&str] = &["▌▌", "▍▍", "▎▎", "▏▏"]; +const TOOL_FRAMES: &[&str] = &["⚙", "⚙", "⚙", "⚙"]; +const APPROVAL_FRAMES: &[&str] = &["◆", "◆", "◆", "◆"]; + +/// Get the color for a given busy state. +pub fn color_for_state(state: BusyState) -> Color { + match state { + BusyState::Idle => Color::Rgb(74, 222, 128), // Green + BusyState::Thinking => Color::Rgb(180, 120, 80), // Brown + BusyState::Streaming => Color::Rgb(255, 165, 0), // Orange + BusyState::ToolRunning => Color::Rgb(94, 234, 212), // Cyan/Teal + BusyState::ApprovalPending => Color::Rgb(251, 191, 36), // Amber/Yellow + BusyState::Error => Color::Rgb(248, 113, 113), // Red + } +} + +/// Get the animated frame for a given state and elapsed time. +pub fn frame_for_state(state: BusyState, elapsed_ms: u128) -> &'static str { + let frames = match state { + BusyState::Thinking => THINKING_FRAMES, + BusyState::Streaming => STREAMING_FRAMES, + BusyState::ToolRunning => TOOL_FRAMES, + BusyState::ApprovalPending => APPROVAL_FRAMES, + _ => return "●", + }; + + let frame_idx = (elapsed_ms / 120) as usize % frames.len(); + frames[frame_idx] +} + +/// Build the busy indicator span with animation. +pub fn render_indicator(state: BusyState, state_since: Instant) -> String { + let elapsed_ms = state_since.elapsed().as_millis(); + let frame = frame_for_state(state, elapsed_ms); + let label = state.label(); + + match state { + BusyState::Idle => format!(" ○ {label} "), + _ => format!(" {frame} {label} "), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn color_for_idle_is_green() { + let c = color_for_state(BusyState::Idle); + assert_eq!(c, Color::Rgb(74, 222, 128)); + } + + #[test] + fn color_for_thinking_is_brown() { + let c = color_for_state(BusyState::Thinking); + assert_eq!(c, Color::Rgb(180, 120, 80)); + } + + #[test] + fn color_for_streaming_is_orange() { + let c = color_for_state(BusyState::Streaming); + assert_eq!(c, Color::Rgb(255, 165, 0)); + } + + #[test] + fn frame_cycles_through_thinking_frames() { + let frames: Vec<&str> = (0..500) + .step_by(120) + .map(|ms| frame_for_state(BusyState::Thinking, ms as u128)) + .collect(); + // Should cycle: ◐ ◓ ◑ ◒ ◐ ... + assert_eq!(frames[0], "◐"); + assert_eq!(frames[1], "◓"); + assert_eq!(frames[2], "◑"); + assert_eq!(frames[3], "◒"); + assert_eq!(frames[4], "◐"); + } + + #[test] + fn render_indicator_idle() { + let ind = render_indicator(BusyState::Idle, Instant::now()); + assert!(ind.contains("idle")); + assert!(ind.contains("○")); + } + + #[test] + fn render_indicator_thinking() { + let ind = render_indicator(BusyState::Thinking, Instant::now()); + assert!(ind.contains("thinking")); + // Should contain one of the thinking frames + assert!(ind.contains("◐") || ind.contains("◓") || ind.contains("◑") || ind.contains("◒")); + } +} diff --git a/crates/cli/src/tui/mod.rs b/crates/cli/src/tui/mod.rs index 01b36d6..2e8fe58 100644 --- a/crates/cli/src/tui/mod.rs +++ b/crates/cli/src/tui/mod.rs @@ -2,6 +2,7 @@ pub mod app; pub mod bridge; +pub mod busy_indicator; pub mod connect_modal; pub mod onboarding; pub mod replay; diff --git a/crates/cli/src/tui/state.rs b/crates/cli/src/tui/state.rs index 681bb5d..db1eadb 100644 --- a/crates/cli/src/tui/state.rs +++ b/crates/cli/src/tui/state.rs @@ -1,7 +1,7 @@ //! Transcript + status driven by `AgentEvent`. use nca_common::config::ProviderKind; -use nca_common::event::{AgentEvent, InteractiveQuestionPayload}; +use nca_common::event::{AgentEvent, BusyState, InteractiveQuestionPayload}; use nca_common::message::ImageAttachment; use serde_json::Value; use std::path::PathBuf; @@ -85,6 +85,10 @@ pub struct TuiSessionState { pub cost_usd: f64, pub started: Instant, pub busy: bool, + /// Current busy state (for animated indicator). + pub current_busy_state: BusyState, + /// When the current busy state started (for animation frame selection). + pub busy_state_since: Instant, pub should_exit: bool, /// Selected row in slash-command popup (↑↓ or click). pub slash_menu_index: usize, @@ -208,6 +212,8 @@ impl TuiSessionState { cost_usd: 0.0, started: Instant::now(), busy: false, + current_busy_state: BusyState::Idle, + busy_state_since: Instant::now(), should_exit: false, slash_menu_index: 0, command_palette_open: false, @@ -380,6 +386,13 @@ impl TuiSessionState { self.busy = busy; } + pub fn set_busy_state(&mut self, state: BusyState) { + if self.current_busy_state != state { + self.current_busy_state = state; + self.busy_state_since = Instant::now(); + } + } + pub fn push_error(&mut self, msg: String) { self.blocks.push(DisplayBlock::ErrorLine(msg)); } @@ -456,15 +469,18 @@ impl TuiSessionState { if role == "user" { self.streaming_assistant = None; self.blocks.push(DisplayBlock::User(content.clone())); + self.set_busy_state(BusyState::Thinking); } else if role == "assistant" { self.streaming_assistant = None; self.blocks.push(DisplayBlock::Assistant(content.clone())); + self.set_busy_state(BusyState::Idle); } } AgentEvent::TokensStreamed { delta } => { self.streaming_assistant .get_or_insert_with(String::new) .push_str(delta); + self.set_busy_state(BusyState::Streaming); } AgentEvent::ToolCallStarted { call_id, @@ -477,6 +493,7 @@ impl TuiSessionState { call_id: call_id.clone(), input: format_tool_input_for_display(tool, input), }); + self.set_busy_state(BusyState::ToolRunning); } AgentEvent::ToolCallCompleted { call_id, output } => { let ok = output.success; @@ -484,6 +501,7 @@ impl TuiSessionState { .active_approval .take() .filter(|req| req.call_id != *call_id); + self.set_busy_state(BusyState::Thinking); let detail = if ok { truncate(&output.output, 120) } else { @@ -532,6 +550,7 @@ impl TuiSessionState { input, }; self.active_approval = Some(req.clone()); + self.set_busy_state(BusyState::ApprovalPending); if let Some(idx) = self.blocks.iter().rposition( |b| matches!(b, DisplayBlock::ToolRunning { call_id: id, .. } if id == call_id), ) { @@ -590,6 +609,7 @@ impl TuiSessionState { } AgentEvent::Error { message } => { self.blocks.push(DisplayBlock::ErrorLine(message.clone())); + self.set_busy_state(BusyState::Error); } AgentEvent::Checkpoint { .. } => {} AgentEvent::ChildSessionSpawned { @@ -666,6 +686,9 @@ impl TuiSessionState { "Sub-agent {short}… done: {status}" ))); } + AgentEvent::BusyStateChanged { state } => { + self.set_busy_state(*state); + } _ => {} } } diff --git a/crates/common/src/event.rs b/crates/common/src/event.rs index 12389db..433309b 100644 --- a/crates/common/src/event.rs +++ b/crates/common/src/event.rs @@ -4,6 +4,39 @@ use std::path::PathBuf; use crate::tool::ToolResult; +/// Real-time busy state indicator for CLI rendering. +/// Reflects the current processing state of the agent. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BusyState { + /// Agent is idle and ready for input. + Idle, + /// Agent is thinking/planning (heavy computation, model reasoning). + Thinking, + /// Agent is streaming tokens from LLM. + Streaming, + /// Agent is executing a tool. + ToolRunning, + /// Agent is waiting for user approval on a tool call. + ApprovalPending, + /// Error or blocked state. + Error, +} + +impl BusyState { + /// Human-readable label for this state. + pub fn label(&self) -> &'static str { + match self { + BusyState::Idle => "idle", + BusyState::Thinking => "thinking", + BusyState::Streaming => "streaming", + BusyState::ToolRunning => "tool", + BusyState::ApprovalPending => "approval", + BusyState::Error => "error", + } + } +} + /// Envelope for events written to disk, with stable id and timestamp for ordering. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EventEnvelope { @@ -116,6 +149,10 @@ pub enum AgentEvent { phase: String, message: String, }, + /// Busy state transition (for animated indicator rendering). + BusyStateChanged { + state: BusyState, + }, } /// One selectable row shown for an interactive question. diff --git a/crates/core/src/agent.rs b/crates/core/src/agent.rs index d84fd52..8999832 100644 --- a/crates/core/src/agent.rs +++ b/crates/core/src/agent.rs @@ -1,5 +1,5 @@ use futures_util::future::join_all; -use nca_common::event::AgentEvent; +use nca_common::event::{AgentEvent, BusyState}; use nca_common::message::{ContentPart, ImageAttachment, Message, MessageToolCall}; use nca_common::tool::{PermissionTier, ToolCall, ToolDefinition, ToolResult}; use serde_json::json; @@ -129,6 +129,10 @@ impl AgentLoop { ))); } + self.emit(AgentEvent::BusyStateChanged { + state: BusyState::Thinking, + }) + .await; self.emit(AgentEvent::Checkpoint { phase: "provider_request".into(), detail: format!("Starting model turn {turn}"), @@ -162,6 +166,12 @@ impl AgentLoop { } match chunk { StreamChunk::TextDelta(delta) => { + if assistant_text.is_empty() { + self.emit(AgentEvent::BusyStateChanged { + state: BusyState::Streaming, + }) + .await; + } assistant_text.push_str(&delta); self.emit(AgentEvent::TokensStreamed { delta }).await; } @@ -495,6 +505,10 @@ impl AgentLoop { .await; } + self.emit(AgentEvent::BusyStateChanged { + state: BusyState::Idle, + }) + .await; Ok(final_text) } From c913e6bbc2de355a8c862fda3fc57e5a7a753252 Mon Sep 17 00:00:00 2001 From: Indra Gunanda Date: Tue, 24 Mar 2026 18:30:29 +0700 Subject: [PATCH 03/11] fix: prevent tool_use/tool_result desync during context truncation (#24) (#27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: add wildcard allow command design spec Design for issue #18 — wildcard allow patterns and Ctrl+U interactive "always allow" shortcut for session-scoped tool approval. * docs: fix spec review issues — correct data flow, JSON-aware extraction Addresses reviewer feedback: - Fix suggest_allow_pattern to handle JSON tool input - Correct TUI approval data flow: app.rs → repl.rs → runner.rs - Add missing files: ipc_pending.rs, runner.rs, repl.rs * docs: add wildcard allow implementation plan 6-task plan covering wildcard matching, session allow list, ApprovalVerdict enum, and Ctrl+U TUI shortcut. * feat: add wildcard_matches function for permission patterns * feat: add extract_meaningful_text and suggest_allow_pattern * feat: add session_allow list and human-readable key matching in check() - Add `session_allow: Vec` field (pub) to ApprovalPolicy - Add `add_session_allow()` method with deduplication - Update `check()` to build both json_key and readable_key (via extract_meaningful_text) - Replace key.contains() with wildcard_matches() for deny and allow checks - Chain session_allow with config.allow in explicitly_allowed check * feat: replace bool approval with ApprovalVerdict enum across all handlers * feat: add Ctrl+U always-allow shortcut with session-scoped wildcard pattern * style: apply cargo fmt across workspace * chore: add cargo-husky pre-commit hooks (fmt, clippy, test) * fix: handle single-word commands in suggest_allow_pattern Pattern for single-token inputs like `ls` now generates `execute_bash:ls*` instead of `execute_bash:ls *` which failed to match because the space was mandatory. Also removes docs/superpowers plan and spec files. * fix: prevent tool_use/tool_result desync during context truncation (#24) Context compaction (sliding window and apply_summary) could split tool_use/tool_result message groups, leaving orphaned tool_result messages without their matching assistant tool_use. This caused the API error: "unexpected tool_use_id found in tool_result blocks". Add adjust_cutoff_for_tool_groups() to walk the cutoff backwards past any Role::Tool messages so the preceding assistant message with tool_calls is always included. --- crates/runtime/src/context_manager.rs | 169 +++++++++++++++++++++++++- 1 file changed, 167 insertions(+), 2 deletions(-) diff --git a/crates/runtime/src/context_manager.rs b/crates/runtime/src/context_manager.rs index 094f03d..e3ff4ae 100644 --- a/crates/runtime/src/context_manager.rs +++ b/crates/runtime/src/context_manager.rs @@ -144,6 +144,22 @@ impl ContextManager { .collect() } + /// Adjust a cutoff index so it never lands inside a tool_use/tool_result group. + /// If the message at `cutoff` is a Role::Tool, walk backwards to include the + /// preceding assistant message that contains the matching tool_calls. + /// `min_cutoff` prevents walking past system messages. + fn adjust_cutoff_for_tool_groups( + messages: &[Message], + mut cutoff: usize, + min_cutoff: usize, + ) -> usize { + while cutoff > min_cutoff && cutoff < messages.len() && messages[cutoff].role == Role::Tool + { + cutoff -= 1; + } + cutoff + } + /// Check if the context needs compaction. pub fn needs_compaction(&self, messages: &[Message]) -> bool { let stats = self.stats(messages); @@ -217,8 +233,9 @@ impl ContextManager { return messages.to_vec(); }; - // Get recent messages to keep (everything after the summarize range) - let recent_start = range.end; + // Get recent messages to keep (everything after the summarize range). + // Adjust to avoid splitting tool_use/tool_result groups. + let recent_start = Self::adjust_cutoff_for_tool_groups(messages, range.end, system_count); // Build new message list: system + summary + recent let mut result = Vec::with_capacity(system_count + 10); @@ -247,6 +264,7 @@ impl ContextManager { /// Get a sliding window of recent messages for context. /// Preserves system messages and keeps recent conversation. + /// Ensures tool_use/tool_result groups are never split. pub fn get_sliding_window( &self, messages: &[Message], @@ -262,6 +280,7 @@ impl ContextManager { // Keep system messages + last (max - system_count) messages let keep_count = max.saturating_sub(system_count); let cutoff = messages.len() - keep_count; + let cutoff = Self::adjust_cutoff_for_tool_groups(messages, cutoff, system_count); let mut result: Vec = messages[..system_count].to_vec(); result.extend_from_slice(&messages[cutoff..]); @@ -452,4 +471,150 @@ mod tests { .any(|m| m.content.to_summary_text().contains("Conversation Summary")) ); } + + use nca_common::message::MessageToolCall; + + fn make_tool_call(id: &str, name: &str) -> MessageToolCall { + MessageToolCall { + id: id.to_string(), + name: name.to_string(), + arguments: serde_json::json!({}), + } + } + + /// Helper: assert no Role::Tool message appears without its matching + /// assistant tool_use in the preceding assistant message. + fn assert_no_orphaned_tool_results(messages: &[Message]) { + let mut expected_tool_ids: std::collections::HashSet = + std::collections::HashSet::new(); + for msg in messages { + match msg.role { + Role::Assistant => { + expected_tool_ids.clear(); + if let Some(calls) = &msg.tool_calls { + for call in calls { + expected_tool_ids.insert(call.id.clone()); + } + } + } + Role::Tool => { + let id = msg.tool_call_id.as_deref().unwrap_or(""); + assert!( + expected_tool_ids.contains(id), + "Orphaned tool_result with id '{}' — no matching tool_use in preceding assistant message. Messages: {:?}", + id, + messages + .iter() + .map(|m| (&m.role, m.tool_call_id.as_deref())) + .collect::>() + ); + } + _ => { + expected_tool_ids.clear(); + } + } + } + } + + #[test] + fn sliding_window_preserves_tool_use_result_pairs() { + // max_retained=4: cutoff would land on tool result msg if naive + let config = ContextManagerConfig { + context_window_target: 32_000, + max_retained_messages: 4, + auto_summarize_threshold: 75, + enable_auto_summarize: true, + max_message_chars_for_summary: 10_000, + }; + let manager = ContextManager::new(config, "test-model".to_string()); + + // 10 messages: user, assistant+tool_calls, tool_result, user, ... + let messages = vec![ + make_message(Role::System, "system prompt"), + make_message(Role::User, "msg 1"), + make_message(Role::Assistant, "reply 1"), + make_message(Role::User, "msg 2"), + // This is the critical group: assistant calls tool, then tool result + Message::assistant_with_tool_calls( + "Let me check", + vec![make_tool_call("call-1", "read_file")], + ), + Message::tool("call-1", "file contents here"), + // After tool result + make_message(Role::User, "msg 3"), + make_message(Role::Assistant, "final reply"), + ]; + + let window = manager.get_sliding_window(&messages, None); + + // The window must never contain an orphaned tool_result + assert_no_orphaned_tool_results(&window); + } + + #[test] + fn apply_summary_preserves_tool_use_result_pairs() { + // max_retained=3: cutoff lands right on the tool_result + let config = ContextManagerConfig { + context_window_target: 32_000, + max_retained_messages: 3, + auto_summarize_threshold: 75, + enable_auto_summarize: true, + max_message_chars_for_summary: 10_000, + }; + let manager = ContextManager::new(config, "test-model".to_string()); + + let messages = vec![ + make_message(Role::System, "system prompt"), + make_message(Role::User, "msg 1"), + make_message(Role::Assistant, "reply 1"), + make_message(Role::User, "msg 2"), + // Tool use group that straddles the naive cutoff + Message::assistant_with_tool_calls( + "Running tool", + vec![make_tool_call("call-2", "bash")], + ), + Message::tool("call-2", "ok"), + make_message(Role::User, "msg 3"), + make_message(Role::Assistant, "done"), + ]; + + let result = manager.apply_summary(&messages, "Earlier conversation summary."); + + assert_no_orphaned_tool_results(&result); + } + + #[test] + fn sliding_window_preserves_multi_tool_call_group() { + // Assistant calls 2 tools → 2 tool results. Window must keep entire group. + let config = ContextManagerConfig { + context_window_target: 32_000, + max_retained_messages: 4, + auto_summarize_threshold: 75, + enable_auto_summarize: true, + max_message_chars_for_summary: 10_000, + }; + let manager = ContextManager::new(config, "test-model".to_string()); + + let messages = vec![ + make_message(Role::System, "system prompt"), + make_message(Role::User, "msg 1"), + make_message(Role::Assistant, "reply 1"), + make_message(Role::User, "msg 2"), + Message::assistant_with_tool_calls( + "Using two tools", + vec![ + make_tool_call("call-a", "read_file"), + make_tool_call("call-b", "grep"), + ], + ), + Message::tool("call-a", "contents a"), + Message::tool("call-b", "contents b"), + make_message(Role::User, "msg 3"), + make_message(Role::Assistant, "all done"), + ]; + + let window = manager.get_sliding_window(&messages, None); + + assert_no_orphaned_tool_results(&window); + } } From 94dd5ec8ad0965419ddbaf53f4803f8f2d20a0a3 Mon Sep 17 00:00:00 2001 From: Aris Setiawan Date: Tue, 24 Mar 2026 19:55:47 +0700 Subject: [PATCH 04/11] feat: enhance user event preview and at-mention handling (#31) - Added functionality to compact expanded file blocks in user event previews, replacing them with a simplified format (e.g., `@path`). - Introduced new methods for handling at-mentions, including selection, deletion, and cursor styling in the TUI. - Updated the composer line rendering to visually distinguish at-mentions with a specific background color. - Improved whitespace handling in message content to ensure clean formatting. --- crates/cli/src/tui/app.rs | 279 +++++++++++++++--- crates/common/src/message.rs | 114 ++++++- docs/plans/file-mention-preview-compaction.md | 17 ++ docs/plans/tui-mention-chip-ux.md | 17 ++ docs/plans/tui-mention-enter-selection.md | 17 ++ 5 files changed, 400 insertions(+), 44 deletions(-) create mode 100644 docs/plans/file-mention-preview-compaction.md create mode 100644 docs/plans/tui-mention-chip-ux.md create mode 100644 docs/plans/tui-mention-enter-selection.md diff --git a/crates/cli/src/tui/app.rs b/crates/cli/src/tui/app.rs index 9066358..85c235f 100644 --- a/crates/cli/src/tui/app.rs +++ b/crates/cli/src/tui/app.rs @@ -113,6 +113,7 @@ mod theme { pub const BG: Color = Color::Rgb(22, 22, 28); pub const SURFACE: Color = Color::Rgb(32, 32, 42); pub const BORDER: Color = Color::Rgb(55, 55, 70); + pub const MENTION_BG: Color = Color::Rgb(48, 62, 94); pub const USER: Color = Color::Rgb(56, 189, 248); pub const ASSISTANT: Color = Color::Rgb(167, 139, 250); @@ -207,6 +208,154 @@ fn apply_at_completion(buffer: &str, cursor_char_idx: usize, choice: &str) -> (S (new_buf, new_char) } +fn apply_selected_at_completion( + workspace_files: &[String], + buffer: &str, + cursor_char_idx: usize, + at_menu_index: usize, + append_space: bool, +) -> Option<(String, usize)> { + let at_matches = at_completion_matches(workspace_files, buffer, cursor_char_idx); + if at_matches.is_empty() || !at_completion_active(buffer, cursor_char_idx) { + return None; + } + + let pick = at_menu_index.min(at_matches.len().saturating_sub(1)); + let choice = at_matches.get(pick)?; + let (mut new_buf, mut new_cursor_char_idx) = + apply_at_completion(buffer, cursor_char_idx, choice); + + if append_space { + let insert_at = cursor_byte_index(&new_buf, new_cursor_char_idx); + new_buf.insert(insert_at, ' '); + new_cursor_char_idx += 1; + } + + Some((new_buf, new_cursor_char_idx)) +} + +fn at_mention_char_ranges(buffer: &str) -> Vec<(usize, usize)> { + file_mentions::parse_at_mentions(buffer) + .into_iter() + .map(|(start, end, _)| { + let start_char = buffer[..start].chars().count(); + let end_char = buffer[..end].chars().count(); + (start_char, end_char) + }) + .collect() +} + +fn completed_at_mention_range_before_cursor( + buffer: &str, + cursor_char_idx: usize, +) -> Option<(usize, usize)> { + let chars: Vec = buffer.chars().collect(); + for (start_char, end_char) in at_mention_char_ranges(buffer) { + if end_char == cursor_char_idx { + return Some((start_char, end_char)); + } + if end_char < chars.len() + && end_char + 1 == cursor_char_idx + && chars.get(end_char) == Some(&' ') + { + return Some((start_char, end_char + 1)); + } + } + None +} + +fn remove_char_range(buffer: &str, start_char_idx: usize, end_char_idx: usize) -> String { + let mut chars: Vec = buffer.chars().collect(); + chars.drain(start_char_idx..end_char_idx); + chars.into_iter().collect() +} + +fn delete_completed_at_mention(buffer: &str, cursor_char_idx: usize) -> Option<(String, usize)> { + let (start_char, end_char) = completed_at_mention_range_before_cursor(buffer, cursor_char_idx)?; + Some((remove_char_range(buffer, start_char, end_char), start_char)) +} + +fn push_styled_run( + spans: &mut Vec>, + text: &mut String, + current_style: &mut Option