Skip to content

Commit fd61031

Browse files
ilblackdragonclaude
andcommitted
feat(web): add follow-up suggestion chips and ghost text (#1156)
* feat(web): add follow-up suggestion chips and ghost text to chat UI The LLM now always generates 1-3 follow-up command suggestions via <suggestions> tags in its response. These are extracted server-side, broadcast as SSE events, and rendered as clickable chips above the chat input. The first suggestion also appears as ghost text in the input field (Tab to accept). Includes debug logging for LLM responses in the agentic loop and removes noisy NEAR AI status logging. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: resolve deferred review items from PR #1156 [skip-regression-check] - Remove literal backslashes from raw string prompt (reasoning.rs) - Make WASM channels skip Suggestions status (no-op instead of empty callback) - Add !e.shiftKey guard to Tab-to-accept ghost text handler - Cap extracted suggestions at 3 and trim whitespace-only entries - Extract suggestions in approval-resume path (prevents tag leaking) - Remove stale .has-ghost class during showSuggestionChips reset Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent daeb6a6 commit fd61031

14 files changed

Lines changed: 368 additions & 35 deletions

File tree

src/agent/agentic_loop.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,30 @@ pub async fn run_agentic_loop(
152152
// Call LLM
153153
let output = delegate.call_llm(reasoning, reason_ctx, iteration).await?;
154154

155+
match &output.result {
156+
RespondResult::Text(text) => {
157+
tracing::debug!(
158+
iteration,
159+
len = text.len(),
160+
has_suggestions = text.contains("<suggestions>"),
161+
response = %text,
162+
"LLM text response"
163+
);
164+
}
165+
RespondResult::ToolCalls {
166+
tool_calls,
167+
content,
168+
} => {
169+
let names: Vec<&str> = tool_calls.iter().map(|tc| tc.name.as_str()).collect();
170+
tracing::debug!(
171+
iteration,
172+
tools = ?names,
173+
has_content = content.is_some(),
174+
"LLM tool_calls response"
175+
);
176+
}
177+
}
178+
155179
match output.result {
156180
RespondResult::Text(text) => {
157181
// Tool intent nudge: if the LLM says "let me search..." without

src/agent/dispatcher.rs

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1051,6 +1051,54 @@ fn strip_internal_tool_call_text(text: &str) -> String {
10511051
}
10521052
}
10531053

1054+
/// Extract `<suggestions>["...","..."]</suggestions>` from a response string.
1055+
///
1056+
/// Returns `(cleaned_text, suggestions)`. The `<suggestions>` block is stripped
1057+
/// from the text regardless of whether the JSON inside parses successfully.
1058+
/// Only the **last** `<suggestions>` block is used (closest to end of response).
1059+
/// Blocks inside markdown code fences are ignored.
1060+
pub(crate) fn extract_suggestions(text: &str) -> (String, Vec<String>) {
1061+
use regex::Regex;
1062+
use std::sync::LazyLock;
1063+
1064+
static RE: LazyLock<Regex> = LazyLock::new(|| {
1065+
Regex::new(r"(?s)<suggestions>\s*(.*?)\s*</suggestions>").expect("valid regex") // safety: constant pattern
1066+
});
1067+
1068+
// Find the position of the last closing code fence to avoid matching inside code blocks
1069+
let last_code_fence = text.rfind("```").unwrap_or(0);
1070+
1071+
// Find all matches, take the last one that's after the last code fence
1072+
let mut best_match: Option<regex::Match<'_>> = None;
1073+
let mut best_capture: Option<String> = None;
1074+
for caps in RE.captures_iter(text) {
1075+
if let (Some(full), Some(inner)) = (caps.get(0), caps.get(1))
1076+
&& full.start() >= last_code_fence
1077+
{
1078+
best_match = Some(full);
1079+
best_capture = Some(inner.as_str().to_string());
1080+
}
1081+
}
1082+
1083+
let Some(full) = best_match else {
1084+
return (text.to_string(), Vec::new());
1085+
};
1086+
1087+
let cleaned = format!("{}{}", &text[..full.start()], &text[full.end()..]); // safety: regex match boundaries are valid UTF-8
1088+
let cleaned = cleaned.trim().to_string();
1089+
1090+
// Parse the JSON array
1091+
let suggestions = best_capture
1092+
.and_then(|json| serde_json::from_str::<Vec<String>>(&json).ok())
1093+
.unwrap_or_default()
1094+
.into_iter()
1095+
.filter(|s| !s.trim().is_empty() && s.len() <= 80)
1096+
.take(3)
1097+
.collect();
1098+
1099+
(cleaned, suggestions)
1100+
}
1101+
10541102
#[cfg(test)]
10551103
mod tests {
10561104
use std::sync::Arc;
@@ -2197,6 +2245,55 @@ mod tests {
21972245
assert_eq!(result, input);
21982246
}
21992247

2248+
#[test]
2249+
fn test_extract_suggestions_basic() {
2250+
let input = "Here is my answer.\n<suggestions>[\"Check logs\", \"Deploy\"]</suggestions>";
2251+
let (text, suggestions) = super::extract_suggestions(input);
2252+
assert_eq!(text, "Here is my answer."); // safety: test
2253+
assert_eq!(suggestions, vec!["Check logs", "Deploy"]); // safety: test
2254+
}
2255+
2256+
#[test]
2257+
fn test_extract_suggestions_no_tag() {
2258+
let input = "Just a plain response.";
2259+
let (text, suggestions) = super::extract_suggestions(input);
2260+
assert_eq!(text, "Just a plain response."); // safety: test
2261+
assert!(suggestions.is_empty()); // safety: test
2262+
}
2263+
2264+
#[test]
2265+
fn test_extract_suggestions_malformed_json() {
2266+
let input = "Answer.\n<suggestions>not json</suggestions>";
2267+
let (text, suggestions) = super::extract_suggestions(input);
2268+
assert_eq!(text, "Answer."); // safety: test
2269+
assert!(suggestions.is_empty()); // safety: test
2270+
}
2271+
2272+
#[test]
2273+
fn test_extract_suggestions_inside_code_fence() {
2274+
let input = "```\n<suggestions>[\"foo\"]</suggestions>\n```";
2275+
let (text, suggestions) = super::extract_suggestions(input);
2276+
// The tag is inside a code fence, so it should not be extracted
2277+
assert_eq!(text, input); // safety: test
2278+
assert!(suggestions.is_empty()); // safety: test
2279+
}
2280+
2281+
#[test]
2282+
fn test_extract_suggestions_after_code_fence() {
2283+
let input = "```\ncode\n```\nAnswer.\n<suggestions>[\"foo\"]</suggestions>";
2284+
let (text, suggestions) = super::extract_suggestions(input);
2285+
assert_eq!(text, "```\ncode\n```\nAnswer."); // safety: test
2286+
assert_eq!(suggestions, vec!["foo"]); // safety: test
2287+
}
2288+
2289+
#[test]
2290+
fn test_extract_suggestions_filters_long() {
2291+
let long = "x".repeat(81);
2292+
let input = format!("Answer.\n<suggestions>[\"{}\", \"ok\"]</suggestions>", long);
2293+
let (_, suggestions) = super::extract_suggestions(&input);
2294+
assert_eq!(suggestions, vec!["ok"]); // safety: test
2295+
}
2296+
22002297
#[test]
22012298
fn test_tool_error_format_includes_tool_name() {
22022299
// Regression test for issue #487: tool errors sent to the LLM should

src/agent/thread_ops.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,10 @@ impl Agent {
420420
// Complete, fail, or request approval
421421
match result {
422422
Ok(AgenticLoopResult::Response(response)) => {
423+
// Extract <suggestions> from response text before user sees it
424+
let (response, suggestions) =
425+
crate::agent::dispatcher::extract_suggestions(&response);
426+
423427
// Hook: TransformResponse — allow hooks to modify or reject the final response
424428
let response = {
425429
let event = crate::hooks::HookEvent::ResponseTransform {
@@ -473,6 +477,18 @@ impl Agent {
473477
)
474478
.await;
475479

480+
// Send suggestions after response (best-effort, rendered by web gateway)
481+
if !suggestions.is_empty() {
482+
let _ = self
483+
.channels
484+
.send_status(
485+
&message.channel,
486+
StatusUpdate::Suggestions { suggestions },
487+
&message.metadata,
488+
)
489+
.await;
490+
}
491+
476492
Ok(SubmissionResult::response(response))
477493
}
478494
Ok(AgenticLoopResult::NeedApproval { pending }) => {
@@ -1334,6 +1350,8 @@ impl Agent {
13341350

13351351
match result {
13361352
Ok(AgenticLoopResult::Response(response)) => {
1353+
let (response, suggestions) =
1354+
crate::agent::dispatcher::extract_suggestions(&response);
13371355
thread.complete_turn(&response);
13381356
let (turn_number, tool_calls) = thread
13391357
.turns
@@ -1364,6 +1382,16 @@ impl Agent {
13641382
&message.metadata,
13651383
)
13661384
.await;
1385+
if !suggestions.is_empty() {
1386+
let _ = self
1387+
.channels
1388+
.send_status(
1389+
&message.channel,
1390+
StatusUpdate::Suggestions { suggestions },
1391+
&message.metadata,
1392+
)
1393+
.await;
1394+
}
13671395
Ok(SubmissionResult::response(response))
13681396
}
13691397
Ok(AgenticLoopResult::NeedApproval {

src/channels/channel.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,8 @@ pub enum StatusUpdate {
238238
/// Optional workspace path where the image was saved.
239239
path: Option<String>,
240240
},
241+
/// Suggested follow-up messages for the user.
242+
Suggestions { suggestions: Vec<String> },
241243
}
242244

243245
impl StatusUpdate {

src/channels/repl.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,9 @@ impl Channel for ReplChannel {
607607
eprintln!("\x1b[36m [image generated]\x1b[0m");
608608
}
609609
}
610+
StatusUpdate::Suggestions { .. } => {
611+
// Suggestions are only rendered by the web gateway
612+
}
610613
}
611614
Ok(())
612615
}

0 commit comments

Comments
 (0)