Skip to content

Commit 7d4c6ce

Browse files
yogthosYogthos
andauthored
feat: granular text selection + workflow on-response example (#55)
Two items from the deferred audit list. ## Granular text selection Selection model was line-only: clicking a chat line selected the whole line, dragging extended row-by-row. Users couldn't select a substring within a single line (a paragraph fragment, half a filename, the right half of a diff hunk). Selection anchors are now `(buffer_line_index, char_offset_in_line)` pairs instead of `Option<usize>`. New `Renderer::buffer_pos_at(row, col) -> Option<(usize, usize)>` maps mouse coordinates to a buffer position, accounting for `content_indent()` and clamping at end-of-line so dragging past the right edge anchors there. The viewport paint splits each row into up to three painted runs (pre-selection, selected with `Attribute::Reverse`, post-selection) so highlighting happens per-character within a line. Bold-glow for bright colors is preserved across all three runs. `selected_text` extracts: - single-row: char slice [start_col, end_col) - multi-row: tail of start row + full middle rows + head of end row Reverse drag (end before start in row-major order) is normalized so the extracted text is always the visible selection. Mouse handlers (`MouseDown`, `MouseDrag`, `MouseUp`) now pass `col` through to `buffer_pos_at` instead of dropping it. UTF-8 safe: char counts use `chars().count()` everywhere, so `é` and `🦀` each count as 1 char, not their byte widths. ## workflow.janet on-response example New `plugins/response_inspector.janet` demonstrates the `on-response` hook's two distinct capabilities: 1. **Pattern detection + notifications** — calls `harness/notify` when the agent's reply contains a code block. 2. **Steering string return value** — returns a system-prompt suffix to be applied on the next turn, asking the agent to add inline comments when its previous code block lacked them. Documents the difference between `on-response` (assistant text) and `on-tool-end` (tool output, where `harness/replace-result` belongs). ## Test plan - [x] 6 new tests in `ui::renderer::tests`: - single-row substring selection - reverse-drag normalization - multi-row spans (tail + middle + head) - empty selection returns None - UTF-8 char-index correctness (`café 🦀`) - `buffer_pos_at` clamps past-EOL - [x] `cargo test --features plugin` -> 612 pass, 0 fail. - [x] Both build profiles -> 0 warnings. - [ ] Eyeball: drag in chat to select a substring; verify reverse-video highlight only covers the selected chars, clipboard receives the right substring. Co-authored-by: Yogthos <yogthos@gmail.com>
1 parent bf5e98d commit 7d4c6ce

4 files changed

Lines changed: 284 additions & 67 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@ Example plugins in [`plugins/`](plugins/):
243243
| `local_openai.janet` | `harness/register-provider` declaring vLLM/Ollama/LMStudio local endpoints |
244244
| `session_tree.janet` | `harness/set-label` + `harness/new-session``/label` and `/fresh` slash commands |
245245
| `turn_timer/` | Multi-file plugin — state, hooks, and a `/timer-stats` command split across three files in a single directory |
246+
| `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 |
246247

247248
## LSP integration
248249

plugins/response_inspector.janet

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Response inspector example
2+
#
3+
# Demonstrates the `on-response` hook — the host calls it after every
4+
# LLM response with `(ctx :response)` set to the full assistant
5+
# message text. This hook is the natural place to:
6+
#
7+
# 1. Inspect the response for patterns and POST NOTIFICATIONS via
8+
# `harness/notify` (chat-visible, fire-and-forget).
9+
# 2. Return a string to be APPENDED to the system prompt on the
10+
# next turn — useful for steering subsequent responses based on
11+
# patterns in this one (e.g. "the model is being terse, ask it
12+
# to be more thorough next time").
13+
# 3. Inspect tool calls embedded in the response by piping through
14+
# `on-tool-end` (which fires PER tool call and is the right
15+
# place for `harness/replace-result` if you want to rewrite the
16+
# tool output the LLM sees).
17+
#
18+
# This example posts a notification when the response contains a
19+
# code block, and gently nudges the model to add more comments if
20+
# the previous response had unannotated code.
21+
22+
(def hooks [])
23+
24+
(var last-had-code-block false)
25+
26+
(defn response_inspector-on-response [ctx]
27+
(let [text (or (ctx :response) "")]
28+
# 1. Notification when a response includes a code block.
29+
(when (string/find "```" text)
30+
(harness/notify "agent included a code block" :info)
31+
(set last-had-code-block true))
32+
33+
# 2. Steering string: if the prior response had code but no
34+
# `;` or `//` comment lines, append a system-prompt hint for
35+
# the next turn asking for more annotations.
36+
(if (and last-had-code-block
37+
(string/find "```" text)
38+
(not (or (string/find ";; " text)
39+
(string/find "// " text)
40+
(string/find "# " text))))
41+
(do
42+
(set last-had-code-block false)
43+
# The returned string is appended to the system prompt
44+
# injection for the next turn.
45+
(string "The previous response included code but no inline "
46+
"comments. When writing code, briefly annotate the "
47+
"*why* of non-obvious lines (one short comment per "
48+
"non-trivial block)."))
49+
(do
50+
(set last-had-code-block false)
51+
nil))))

src/ui/mod.rs

Lines changed: 27 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -618,32 +618,34 @@ pub async fn run_interactive(
618618
)?;
619619
continue;
620620
}
621-
UserEvent::MouseDown { row, col: _ } => {
621+
UserEvent::MouseDown { row, col } => {
622622
if row < renderer.visible_lines() as u16
623-
&& let Some(idx) = renderer.buffer_line_at_row(row) {
624-
renderer.selection_active = true;
625-
renderer.selection_start = Some(idx);
626-
renderer.selection_end = Some(idx);
627-
renderer.render_viewport()?;
628-
renderer.draw_bottom(
629-
&input,
630-
&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()),
631-
is_running,
632-
)?;
633-
}
623+
&& let Some(pos) = renderer.buffer_pos_at(row, col)
624+
{
625+
renderer.selection_active = true;
626+
renderer.selection_start = Some(pos);
627+
renderer.selection_end = Some(pos);
628+
renderer.render_viewport()?;
629+
renderer.draw_bottom(
630+
&input,
631+
&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()),
632+
is_running,
633+
)?;
634+
}
634635
continue;
635636
}
636-
UserEvent::MouseDrag { row, col: _ } => {
637+
UserEvent::MouseDrag { row, col } => {
637638
if renderer.selection_active
638-
&& let Some(idx) = renderer.buffer_line_at_row(row) {
639-
renderer.selection_end = Some(idx);
640-
renderer.render_viewport()?;
641-
renderer.draw_bottom(
642-
&input,
643-
&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()),
644-
is_running,
645-
)?;
646-
}
639+
&& let Some(pos) = renderer.buffer_pos_at(row, col)
640+
{
641+
renderer.selection_end = Some(pos);
642+
renderer.render_viewport()?;
643+
renderer.draw_bottom(
644+
&input,
645+
&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()),
646+
is_running,
647+
)?;
648+
}
647649
continue;
648650
}
649651
UserEvent::Paste(text) => {
@@ -668,10 +670,10 @@ pub async fn run_interactive(
668670
)?;
669671
continue;
670672
}
671-
UserEvent::MouseUp { row, col: _ } => {
673+
UserEvent::MouseUp { row, col } => {
672674
if renderer.selection_active {
673-
if let Some(idx) = renderer.buffer_line_at_row(row) {
674-
renderer.selection_end = Some(idx);
675+
if let Some(pos) = renderer.buffer_pos_at(row, col) {
676+
renderer.selection_end = Some(pos);
675677
}
676678
if let Some(text) = renderer.selected_text() {
677679
copy_to_clipboard(&text);

0 commit comments

Comments
 (0)