diff --git a/README.md b/README.md index 18723076..9dbb95a2 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,7 @@ Example plugins in [`plugins/`](plugins/): | `local_openai.janet` | `harness/register-provider` declaring vLLM/Ollama/LMStudio local endpoints | | `session_tree.janet` | `harness/set-label` + `harness/new-session` — `/label` and `/fresh` slash commands | | `turn_timer/` | Multi-file plugin — state, hooks, and a `/timer-stats` command split across three files in a single directory | +| `response_inspector.janet` | `on-response` hook — pattern-match the LLM's reply, post notifications, and return a steering string appended to the next turn's system prompt | ## LSP integration diff --git a/plugins/response_inspector.janet b/plugins/response_inspector.janet new file mode 100644 index 00000000..da3f800e --- /dev/null +++ b/plugins/response_inspector.janet @@ -0,0 +1,51 @@ +# Response inspector example +# +# Demonstrates the `on-response` hook — the host calls it after every +# LLM response with `(ctx :response)` set to the full assistant +# message text. This hook is the natural place to: +# +# 1. Inspect the response for patterns and POST NOTIFICATIONS via +# `harness/notify` (chat-visible, fire-and-forget). +# 2. Return a string to be APPENDED to the system prompt on the +# next turn — useful for steering subsequent responses based on +# patterns in this one (e.g. "the model is being terse, ask it +# to be more thorough next time"). +# 3. Inspect tool calls embedded in the response by piping through +# `on-tool-end` (which fires PER tool call and is the right +# place for `harness/replace-result` if you want to rewrite the +# tool output the LLM sees). +# +# This example posts a notification when the response contains a +# code block, and gently nudges the model to add more comments if +# the previous response had unannotated code. + +(def hooks []) + +(var last-had-code-block false) + +(defn response_inspector-on-response [ctx] + (let [text (or (ctx :response) "")] + # 1. Notification when a response includes a code block. + (when (string/find "```" text) + (harness/notify "agent included a code block" :info) + (set last-had-code-block true)) + + # 2. Steering string: if the prior response had code but no + # `;` or `//` comment lines, append a system-prompt hint for + # the next turn asking for more annotations. + (if (and last-had-code-block + (string/find "```" text) + (not (or (string/find ";; " text) + (string/find "// " text) + (string/find "# " text)))) + (do + (set last-had-code-block false) + # The returned string is appended to the system prompt + # injection for the next turn. + (string "The previous response included code but no inline " + "comments. When writing code, briefly annotate the " + "*why* of non-obvious lines (one short comment per " + "non-trivial block).")) + (do + (set last-had-code-block false) + nil)))) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 21307839..3f8edb76 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -618,32 +618,34 @@ pub async fn run_interactive( )?; continue; } - UserEvent::MouseDown { row, col: _ } => { + UserEvent::MouseDown { row, col } => { if row < renderer.visible_lines() as u16 - && let Some(idx) = renderer.buffer_line_at_row(row) { - renderer.selection_active = true; - renderer.selection_start = Some(idx); - renderer.selection_end = Some(idx); - renderer.render_viewport()?; - renderer.draw_bottom( - &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.len()), - is_running, - )?; - } + && let Some(pos) = renderer.buffer_pos_at(row, col) + { + renderer.selection_active = true; + renderer.selection_start = Some(pos); + renderer.selection_end = Some(pos); + renderer.render_viewport()?; + renderer.draw_bottom( + &input, + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.len()), + is_running, + )?; + } continue; } - UserEvent::MouseDrag { row, col: _ } => { + UserEvent::MouseDrag { row, col } => { if renderer.selection_active - && let Some(idx) = renderer.buffer_line_at_row(row) { - renderer.selection_end = Some(idx); - renderer.render_viewport()?; - renderer.draw_bottom( - &input, - &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.len()), - is_running, - )?; - } + && let Some(pos) = renderer.buffer_pos_at(row, col) + { + renderer.selection_end = Some(pos); + renderer.render_viewport()?; + renderer.draw_bottom( + &input, + &with_queue(StatusLine::render(session, is_running, 0, loop_label.as_deref(), context.current_prompt_name.as_deref(), perm_mode().as_deref()), interjection_queue.len()), + is_running, + )?; + } continue; } UserEvent::Paste(text) => { @@ -668,10 +670,10 @@ pub async fn run_interactive( )?; continue; } - UserEvent::MouseUp { row, col: _ } => { + UserEvent::MouseUp { row, col } => { if renderer.selection_active { - if let Some(idx) = renderer.buffer_line_at_row(row) { - renderer.selection_end = Some(idx); + if let Some(pos) = renderer.buffer_pos_at(row, col) { + renderer.selection_end = Some(pos); } if let Some(text) = renderer.selected_text() { copy_to_clipboard(&text); diff --git a/src/ui/renderer.rs b/src/ui/renderer.rs index 0b273ba5..9cc27d75 100644 --- a/src/ui/renderer.rs +++ b/src/ui/renderer.rs @@ -72,8 +72,12 @@ pub struct Renderer { input_rows: u16, monochrome: bool, pub selection_active: bool, - pub selection_start: Option, - pub selection_end: Option, + /// Selection anchor as `(buffer_line_index, char_offset_in_line)`. + /// Char offset is in *chars* (not bytes) so multi-byte UTF-8 glyphs + /// behave the same as ASCII. `(line, line_len)` is a valid past-the- + /// end position used when dragging past the line's right edge. + pub selection_start: Option<(usize, usize)>, + pub selection_end: Option<(usize, usize)>, panel_mode: PanelMode, /// Most-recently set panel snapshot. The UI rebuilds and pushes this /// before each redraw so render_viewport/draw_bottom can repaint the @@ -242,6 +246,24 @@ impl Renderer { rows.saturating_sub(self.input_rows + 1) } + /// Map a screen `(row, col)` to a `(line_idx, char_col)` anchor for + /// granular selection. `col` is the absolute terminal column; we + /// subtract `content_indent()` to get the char offset within the + /// rendered line, clamped to the line's char count so dragging + /// past the right edge anchors at the end-of-line. + pub fn buffer_pos_at(&self, row: u16, col: u16) -> Option<(usize, usize)> { + let line_idx = self.buffer_line_at_row(row)?; + let entry = self.buffer.get(line_idx)?; + let line_len = entry.text.chars().count(); + let indent = self.content_indent() as u16; + let char_col = if col < indent { + 0 + } else { + (col - indent) as usize + }; + Some((line_idx, char_col.min(line_len))) + } + pub fn buffer_line_at_row(&self, row: u16) -> Option { let (_, rows) = self.terminal_size(); let visible = rows.saturating_sub(self.input_rows + 1) as usize; @@ -266,18 +288,42 @@ impl Renderer { } pub fn selected_text(&self) -> Option { + // Normalize (start, end) so start <= end in row-major order: + // earlier row wins; same row → earlier column wins. let (start, end) = match (self.selection_start, self.selection_end) { - (Some(s), Some(e)) if s <= e => (s, e), + (Some(s), Some(e)) if (s.0, s.1) <= (e.0, e.1) => (s, e), (Some(s), Some(e)) => (e, s), _ => return None, }; let mut result = String::new(); - for i in start..=end { - if let Some(entry) = self.buffer.get(i) { - if !result.is_empty() { - result.push('\n'); + if start.0 == end.0 { + // Single-row selection: substring from start.1 to end.1. + if let Some(entry) = self.buffer.get(start.0) { + let chars: Vec = entry.text.chars().collect(); + let lo = start.1.min(chars.len()); + let hi = end.1.min(chars.len()); + if lo < hi { + result.extend(&chars[lo..hi]); } - result.push_str(&entry.text); + } + } else { + // Multi-row: tail of start row, full middle rows, head of end row. + if let Some(entry) = self.buffer.get(start.0) { + let chars: Vec = entry.text.chars().collect(); + let lo = start.1.min(chars.len()); + result.extend(&chars[lo..]); + } + for i in (start.0 + 1)..end.0 { + result.push('\n'); + if let Some(entry) = self.buffer.get(i) { + result.push_str(&entry.text); + } + } + result.push('\n'); + if let Some(entry) = self.buffer.get(end.0) { + let chars: Vec = entry.text.chars().collect(); + let hi = end.1.min(chars.len()); + result.extend(&chars[..hi]); } } if result.is_empty() { @@ -421,43 +467,79 @@ impl Renderer { let text_chars: usize = if start + i < end { let entry = &self.buffer[start + i]; let line_idx = start + i; - let text: String = entry.text.chars().take(line_cap).collect(); - let actual_chars = text.chars().count(); + let chars: Vec = entry.text.chars().take(line_cap).collect(); + let actual_chars = chars.len(); - let is_selected = self.selection_active + // Resolve the per-row selection span as char indices + // `[sel_lo, sel_hi)` within this row's `chars` slice. + // None when this row has no selected chars. + let sel_span: Option<(usize, usize)> = if self.selection_active && self.selection_start.is_some() && self.selection_end.is_some() - && { - let s = self.selection_start.unwrap(); - let e = self.selection_end.unwrap(); - let lo = s.min(e); - let hi = s.max(e); - line_idx >= lo && line_idx <= hi + { + let s = self.selection_start.unwrap(); + let e = self.selection_end.unwrap(); + // Normalize so `lo` comes before `hi` in row-major + // order: earlier row wins; same row → earlier col. + let (lo, hi) = if (s.0, s.1) <= (e.0, e.1) { + (s, e) + } else { + (e, s) }; + if line_idx < lo.0 || line_idx > hi.0 { + None + } else { + let from = if line_idx == lo.0 { lo.1 } else { 0 }; + let to = if line_idx == hi.0 { + hi.1.min(actual_chars) + } else { + actual_chars + }; + if from < to { Some((from, to)) } else { None } + } + } else { + None + }; - if is_selected { - write!(stdout, "{}", SetAttribute(Attribute::Reverse))?; - } - // Bold attribute simulates the CRT phosphor bloom: on - // most modern terminals it nudges the glyphs to a - // heavier weight and a brighter shade of the chosen - // color. We apply it to bright tones only (the dim - // green / dim grey colors must stay un-bloomed to - // preserve the two-tone phosphor depth shown in the - // reference btop screenshots). let bloom = crate::ui::theme::is_bright(entry.color); - if bloom { - write!(stdout, "{}", SetAttribute(Attribute::Bold))?; - } - write!(stdout, "{}", SetForegroundColor(self.color(entry.color)))?; - write!(stdout, "{}", text)?; - if bloom { - write!(stdout, "{}", SetAttribute(Attribute::NormalIntensity))?; - } - if is_selected { - write!(stdout, "{}", SetAttribute(Attribute::NoReverse))?; + // Paint the row in up to three runs: pre-selection, + // selected (reverse-video), post-selection. When no + // selection touches this row, we just paint the whole + // run once. + let paint_run = |stdout: &mut std::io::Stdout, + slice: &[char], + reverse: bool| + -> io::Result<()> { + if slice.is_empty() { + return Ok(()); + } + if reverse { + write!(stdout, "{}", SetAttribute(Attribute::Reverse))?; + } + if bloom { + write!(stdout, "{}", SetAttribute(Attribute::Bold))?; + } + write!(stdout, "{}", SetForegroundColor(self.color(entry.color)))?; + let s: String = slice.iter().collect(); + write!(stdout, "{}", s)?; + if bloom { + write!(stdout, "{}", SetAttribute(Attribute::NormalIntensity))?; + } + if reverse { + write!(stdout, "{}", SetAttribute(Attribute::NoReverse))?; + } + write!(stdout, "{}", ResetColor)?; + Ok(()) + }; + + match sel_span { + None => paint_run(&mut stdout, &chars, false)?, + Some((from, to)) => { + paint_run(&mut stdout, &chars[..from], false)?; + paint_run(&mut stdout, &chars[from..to], true)?; + paint_run(&mut stdout, &chars[to..], false)?; + } } - write!(stdout, "{}", ResetColor)?; actual_chars } else { 0 @@ -1376,8 +1458,8 @@ mod tests { r.scroll_line_up(); } r.selection_active = true; - r.selection_start = Some(15); - r.selection_end = Some(20); + r.selection_start = Some((15, 0)); + r.selection_end = Some((20, 5)); for i in 0..7 { r.push_buffer_line(LineEntry { @@ -1387,8 +1469,8 @@ mod tests { } // Selection indices are absolute and remain untouched. - assert_eq!(r.selection_start, Some(15)); - assert_eq!(r.selection_end, Some(20)); + assert_eq!(r.selection_start, Some((15, 0))); + assert_eq!(r.selection_end, Some((20, 5))); } // Boundary: a tiny buffer where appending pushes scroll_offset past @@ -1433,6 +1515,87 @@ mod tests { assert_eq!(view_start(&r), pinned_start); } + // --- granular selection ---------------------------------------------- + + fn fresh_with_text(lines: &[&str]) -> Renderer { + let mut r = Renderer::new().unwrap(); + for s in lines { + r.buffer.push(LineEntry { + text: CompactString::new(s), + color: Color::White, + }); + } + r + } + + /// Same-row selection extracts the substring between start.1 and + /// end.1 (char-indexed, exclusive end). + #[test] + fn selected_text_single_row_substring() { + let mut r = fresh_with_text(&["hello world"]); + r.selection_active = true; + r.selection_start = Some((0, 6)); + r.selection_end = Some((0, 11)); + assert_eq!(r.selected_text(), Some("world".to_string())); + } + + /// Reverse drag (end before start) still yields the same substring — + /// `selected_text` normalizes to row-major order. + #[test] + fn selected_text_reverse_drag_normalizes() { + let mut r = fresh_with_text(&["hello world"]); + r.selection_active = true; + r.selection_start = Some((0, 11)); + r.selection_end = Some((0, 6)); + assert_eq!(r.selected_text(), Some("world".to_string())); + } + + /// Multi-row selection takes the tail of the start row, the full + /// middle rows, and the head of the end row. + #[test] + fn selected_text_multi_row_spans_lines() { + let mut r = fresh_with_text(&["first line", "middle", "last line"]); + r.selection_active = true; + r.selection_start = Some((0, 6)); // "line" + r.selection_end = Some((2, 4)); // "last" + assert_eq!(r.selected_text(), Some("line\nmiddle\nlast".to_string())); + } + + /// Same-row empty selection (start == end) returns None — nothing + /// selected yet, just a click. + #[test] + fn selected_text_empty_selection_returns_none() { + let mut r = fresh_with_text(&["hello"]); + r.selection_active = true; + r.selection_start = Some((0, 3)); + r.selection_end = Some((0, 3)); + assert!(r.selected_text().is_none()); + } + + /// Multi-byte UTF-8: char indices ignore byte width. `é` and `🦀` + /// each count as 1 char, not their byte widths. + #[test] + fn selected_text_handles_unicode() { + let mut r = fresh_with_text(&["café 🦀 rust"]); + r.selection_active = true; + r.selection_start = Some((0, 0)); + r.selection_end = Some((0, 6)); // "café 🦀" + assert_eq!(r.selected_text(), Some("café 🦀".to_string())); + } + + /// `buffer_pos_at` clamps char_col to the line's length so dragging + /// past the right edge anchors at end-of-line rather than + /// silently extending past visible content. + #[test] + fn buffer_pos_at_clamps_past_eol() { + let r = fresh_with_text(&["short"]); + // With one buffer line and scroll_offset=0, + // `buffer_line_at_row` returns Some(0) for row 0 (start = 0 + // after saturating, idx = row). + let pos = r.buffer_pos_at(0, 999); + assert_eq!(pos, Some((0, 5))); + } + // --- wrap_input ------------------------------------------------------- fn lines(parts: &[&str]) -> Vec {