Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 100 additions & 15 deletions crates/noa-app/src/auto_approve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -298,23 +298,34 @@ pub(crate) fn detect(
detect_inner(rows, cursor, Some(agent), ctx, state)
}

pub(crate) fn detect_any_agent(
rows: &[RowText],
cursor: Point,
ctx: DetectContext,
state: &AutoApproveState,
) -> Decision {
detect_inner(rows, cursor, None, ctx, state)
}

/// Detect and advance the two-match state machine in one pass. The viewport
/// is scanned (lowercased + signature-matched) at most once per feed: the
/// same `MatchedPrompt` observation feeds both the decision and the state
/// update, instead of each re-running `find_prompt` on the same rows.
pub(crate) fn detect_and_update_any_agent(
rows: &[RowText],
cursor: Point,
ctx: DetectContext,
state: &mut AutoApproveState,
) -> Decision {
let decision = detect_any_agent(rows, cursor, ctx, state);
apply_decision_state(rows, cursor, ctx, state, &decision);
let (decision, matched) = match suppression(ctx, state.disabled_by_runaway) {
Some(reason) => {
// Only the input-cooldown suppressions keep tracking the prompt
// (see `apply_decision_state`), so only they pay for a scan.
let matched = matches!(
reason,
SuppressReason::RecentUserInput | SuppressReason::PasteActive
)
.then(|| find_prompt(rows, cursor, None))
.flatten();
(Decision::Suppressed(reason), matched)
}
None => {
let matched = find_prompt(rows, cursor, None);
(decide(matched.as_ref(), ctx, state), matched)
}
};
apply_decision_state(rows, matched.as_ref(), ctx, state, &decision);
decision
}

Expand All @@ -339,6 +350,7 @@ pub(crate) fn viewport_rows_from_terminal(terminal: &Terminal) -> Vec<RowText> {
.collect()
}

#[cfg(test)]
fn detect_inner(
rows: &[RowText],
cursor: Point,
Expand All @@ -349,8 +361,17 @@ fn detect_inner(
if let Some(reason) = suppression(ctx, state.disabled_by_runaway) {
return Decision::Suppressed(reason);
}
decide(find_prompt(rows, cursor, agent).as_ref(), ctx, state)
}

let Some(matched) = find_prompt(rows, cursor, agent) else {
/// The unsuppressed decision for one observed viewport: `matched` is the
/// prompt `find_prompt` found there (or `None`).
fn decide(
matched: Option<&MatchedPrompt>,
ctx: DetectContext,
state: &AutoApproveState,
) -> Decision {
let Some(matched) = matched else {
return Decision::Hold;
};

Expand Down Expand Up @@ -381,9 +402,12 @@ fn detect_inner(
}
}

/// Advance the state machine after `decision`. `matched` is the prompt the
/// caller observed on `rows` in the same feed (`None` when none was found,
/// or when the suppression reason makes tracking one pointless).
fn apply_decision_state(
rows: &[RowText],
cursor: Point,
matched: Option<&MatchedPrompt>,
ctx: DetectContext,
state: &mut AutoApproveState,
decision: &Decision,
Expand All @@ -405,7 +429,7 @@ fn apply_decision_state(
});
}
Decision::Hold => {
if let Some(matched) = find_prompt(rows, cursor, None) {
if let Some(matched) = matched {
if state.pending_fire.is_some_and(|pending| {
pending.signature != matched.signature
|| pending.region_hash != matched.region_hash
Expand Down Expand Up @@ -448,7 +472,7 @@ fn apply_decision_state(
reason,
SuppressReason::RecentUserInput | SuppressReason::PasteActive
) {
find_prompt(rows, cursor, None).map(|matched| MatchKey {
matched.map(|matched| MatchKey {
signature: matched.signature,
region_hash: matched.region_hash,
})
Expand Down Expand Up @@ -486,7 +510,16 @@ fn suppression(ctx: DetectContext, disabled_by_runaway: bool) -> Option<Suppress
None
}

#[cfg(test)]
thread_local! {
/// Per-test-thread count of viewport scans, so a test can assert one
/// feed costs exactly one scan (tests run on their own threads).
static PROMPT_SCANS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}

fn find_prompt(rows: &[RowText], cursor: Point, agent: Option<AgentKind>) -> Option<MatchedPrompt> {
#[cfg(test)]
PROMPT_SCANS.with(|scans| scans.set(scans.get() + 1));
let lowercase_rows = lowercase_rows(rows);
SIGNATURES
.iter()
Expand Down Expand Up @@ -772,6 +805,58 @@ mod tests {
])
}

fn prompt_scans() -> usize {
PROMPT_SCANS.with(|scans| scans.get())
}

/// One feed scans the viewport once, whether it holds a prompt (first
/// match: Hold), a second identical match (Fire), or nothing at all.
#[test]
fn detect_and_update_scans_viewport_once_per_feed() {
let now = Instant::now();
let mut state = AutoApproveState::default();
let prompt = rows(&[
"Claude wants to edit crates/noa-app/src/lib.rs",
"❯ 1. Yes",
" 2. No",
]);
let plain = rows(&["plain output", "no prompt here"]);

let before = prompt_scans();
assert_eq!(
detect_and_update_any_agent(&plain, cursor(1), base_ctx(now), &mut state),
Decision::Hold
);
assert_eq!(prompt_scans() - before, 1, "no-prompt feed must scan once");

let before = prompt_scans();
assert_eq!(
detect_and_update_any_agent(&prompt, cursor(1), base_ctx(now), &mut state),
Decision::Hold
);
assert_eq!(prompt_scans() - before, 1, "first match must scan once");

let before = prompt_scans();
assert!(matches!(
detect_and_update_any_agent(&prompt, cursor(1), base_ctx(now), &mut state),
Decision::Fire { .. }
));
assert_eq!(prompt_scans() - before, 1, "firing feed must scan once");

let mut ctx = base_ctx(now);
ctx.guards.last_user_input_at = Some(now);
let before = prompt_scans();
assert!(matches!(
detect_and_update_any_agent(&prompt, cursor(1), ctx, &mut state),
Decision::Suppressed(SuppressReason::RecentUserInput)
));
assert_eq!(
prompt_scans() - before,
1,
"input-cooldown feed must scan once"
);
}

fn rows(input: &[&str]) -> Vec<RowText> {
input.iter().map(|line| (*line).to_string()).collect()
}
Expand Down
69 changes: 53 additions & 16 deletions crates/noa-render/src/renderer/cell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ pub(super) fn rebuild_cell_instances(
let mut bg_rows = Vec::with_capacity(snap.rows.len());
let mut glyph_rows = Vec::with_capacity(snap.rows.len());
let mut deco_rows = Vec::with_capacity(snap.rows.len());
let mut scratch = RowBuildScratch::default();
for (row_idx, row) in snap.rows.iter().enumerate() {
let mut bg = Vec::new();
let mut glyph = Vec::new();
Expand All @@ -54,6 +55,7 @@ pub(super) fn rebuild_cell_instances(
theme,
target_format_is_srgb,
metrics,
&mut scratch,
RowInstanceBuffers {
bg: &mut bg,
glyph: &mut glyph,
Expand All @@ -75,6 +77,18 @@ pub(super) fn rebuild_cell_instances(
(clear_color, (metrics.cell_w, metrics.cell_h))
}

/// Per-row working memory [`rebuild_row_instances`] refills for every row it
/// builds. One instance lives on the `Renderer` and is threaded through
/// [`update_pane_cache`], so the intermediate segmentation / highlight /
/// run buffers are allocated once and reused instead of being rebuilt from
/// zero capacity for each dirty row.
#[derive(Default)]
pub(super) struct RowBuildScratch {
segment_cells: Vec<SegmentCell>,
runs: RunPool,
highlights: Vec<CellHighlight>,
}

/// The three parallel output bands [`rebuild_row_instances`] fills in place,
/// grouped into one param so the caller's `PaneRenderCache` row slots (or a
/// scratch `Vec` in tests) pass as a single unit instead of three positional
Expand Down Expand Up @@ -112,6 +126,7 @@ pub(super) fn rebuild_row_instances(
theme: &Theme,
target_format_is_srgb: bool,
metrics: Metrics,
scratch: &mut RowBuildScratch,
out: RowInstanceBuffers<'_>,
) {
// In/out Vec params (not return-by-value): the caller owns the
Expand All @@ -126,7 +141,12 @@ pub(super) fn rebuild_row_instances(
bg_instances.clear();
glyph_instances.clear();
decoration_instances.clear();
let mut segment_cells = Vec::with_capacity(row.cells.len());
let RowBuildScratch {
segment_cells,
runs,
highlights,
} = scratch;
segment_cells.clear();

// Cursor shape only depends on pane-wide snapshot state (position,
// DECSCUSR style, focus, blink phase), so it is resolved once per row
Expand All @@ -137,7 +157,7 @@ pub(super) fn rebuild_row_instances(
} else {
cursor_visual_for(snap)
};
let row_highlights = RowHighlights::new(snap, y, row.cells.len());
let row_highlights = RowHighlights::new(snap, y, row.cells.len(), highlights);
// Restored-record gutter (`scrollback-persist` spec §5): this row's
// session-absolute index falls inside a persisted-snapshot range the
// caller restored, so column 0 gets an extra marker quad below — unless
Expand Down Expand Up @@ -357,9 +377,10 @@ pub(super) fn rebuild_row_instances(
// becomes an extra instance anchored at its base cell, positioned
// by its own shaped offset instead of an independent per-char pen
// bearing (REQ-SHAPE-4).
for run in segment_row(font, &segment_cells) {
segment_row_into(font, segment_cells, runs);
for run in runs.runs() {
let shaped = font.shape_run(&run.cells);
emit_run_glyph_instances(glyph_instances, font, &run, &shaped, y, metrics);
emit_run_glyph_instances(glyph_instances, font, run, &shaped, y, metrics);
}
}

Expand All @@ -370,25 +391,27 @@ pub(super) struct CellHighlight {
search_match: bool,
}

pub(super) struct RowHighlights {
cells: Option<Vec<CellHighlight>>,
pub(super) struct RowHighlights<'a> {
cells: Option<&'a [CellHighlight]>,
}

impl RowHighlights {
fn new(snap: &FrameSnapshot, y: u16, cols: usize) -> Self {
impl<'a> RowHighlights<'a> {
fn new(snap: &FrameSnapshot, y: u16, cols: usize, buf: &'a mut Vec<CellHighlight>) -> Self {
if cols == 0 || (snap.selection.is_none() && snap.search.matches().is_empty()) {
return Self { cells: None };
}

let mut cells = vec![CellHighlight::default(); cols];
buf.clear();
buf.resize(cols, CellHighlight::default());
let cells = buf.as_mut_slice();

let storage_y = snap.row_base + y as usize;
if let Some(selection) = snap.selection {
let (start, end) = selection.normalized();
if start.y <= storage_y && storage_y <= end.y {
let start_x = if storage_y == start.y { start.x } else { 0 };
let end_x = if storage_y == end.y { end.x } else { u16::MAX };
mark_highlight_span(&mut cells, start_x, end_x, |cell| {
mark_highlight_span(cells, start_x, end_x, |cell| {
cell.selected = true;
});
}
Expand All @@ -406,7 +429,7 @@ impl RowHighlights {
} else {
u16::MAX
};
mark_highlight_span(&mut cells, start_x, end_x, |cell| {
mark_highlight_span(cells, start_x, end_x, |cell| {
cell.search_match = true;
cell.active_search |= active == Some(*search_match);
});
Expand All @@ -417,7 +440,6 @@ impl RowHighlights {

fn get(&self, idx: usize) -> CellHighlight {
self.cells
.as_ref()
.and_then(|cells| cells.get(idx))
.copied()
.unwrap_or_default()
Expand Down Expand Up @@ -498,7 +520,15 @@ pub(super) fn rebuild_pane_cached(
theme: &Theme,
target_format_is_srgb: bool,
) -> PaneRebuild {
let result = update_pane_cache(cache, snap, font, theme, target_format_is_srgb);
let mut scratch = RowBuildScratch::default();
let result = update_pane_cache(
cache,
snap,
font,
theme,
target_format_is_srgb,
&mut scratch,
);
instances.extend_from_slice(&cache.flat);
instances.extend_from_slice(&cache.overlays);
result
Expand All @@ -513,6 +543,7 @@ pub(super) fn update_pane_cache(
font: &mut FontGrid,
theme: &Theme,
target_format_is_srgb: bool,
scratch: &mut RowBuildScratch,
) -> PaneRebuild {
let metrics = font.metrics();
let clear_color = surface_output_rgba(
Expand Down Expand Up @@ -662,10 +693,15 @@ pub(super) fn update_pane_cache(
}
}

// A full rebuild invalidates every row's CONTENT, not its storage:
// keep each row slot's capacity (every row is rebuilt below, and
// `rebuild_row_instances` clears the slot first) so a selection
// change or theme swap does not free and re-grow every row buffer.
// Only a row-count change touches the outer vectors.
if full {
cache.bg = vec![Vec::new(); rows];
cache.glyph = vec![Vec::new(); rows];
cache.deco = vec![Vec::new(); rows];
cache.bg.resize_with(rows, Vec::new);
cache.glyph.resize_with(rows, Vec::new);
cache.deco.resize_with(rows, Vec::new);
cache.flat.clear();
}

Expand All @@ -680,6 +716,7 @@ pub(super) fn update_pane_cache(
theme,
target_format_is_srgb,
metrics,
scratch,
RowInstanceBuffers {
bg: &mut cache.bg[row_idx],
glyph: &mut cache.glyph[row_idx],
Expand Down
6 changes: 5 additions & 1 deletion crates/noa-render/src/renderer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use crate::draw_plan::{DrawOp, PaneId, PaneRect, build_draw_plan};
use crate::image_layer::{ImageDraw, ImageLayer};
use crate::instance::{BlendMode, CellInstance, PaneUniformParams, populate_pane_uniform};
use crate::pipeline::CellPipeline;
use crate::segment::{SegmentCell, ShapeRun, segment_row};
use crate::segment::{RunPool, SegmentCell, ShapeRun, segment_row, segment_row_into};
use crate::snapshot::{
CommandPaletteSnapshot, ConfirmDialogSnapshot, FrameSnapshot, HoverLink,
ImagePlacementSnapshot, PaletteRow, SnapshotImage,
Expand Down Expand Up @@ -358,6 +358,8 @@ pub struct Renderer {
/// pane's stable render-side identity so it survives split reordering
/// across frames.
pane_render_cache: HashMap<PaneId, PaneRenderCache>,
/// Reused per-row build buffers shared by every pane rebuild.
row_scratch: RowBuildScratch,
/// Total rows regenerated across all panes in the most recent
/// `rebuild_panes` call (AC-WP4-02).
rows_rebuilt_last_frame: u64,
Expand Down Expand Up @@ -462,6 +464,7 @@ impl Renderer {
target_format: format,
target_format_is_srgb: format.is_srgb(),
pane_render_cache: HashMap::new(),
row_scratch: RowBuildScratch::default(),
rows_rebuilt_last_frame: 0,
frame_unstable: false,
background_opacity: 1.0,
Expand Down Expand Up @@ -853,6 +856,7 @@ impl Renderer {
font,
theme,
self.target_format_is_srgb,
&mut self.row_scratch,
);
rows_rebuilt_total += rows_rebuilt;
if !stable {
Expand Down
Loading