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) <[email protected]>

* 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) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-03-14 18:57:24 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent f9b880c2e9
commit 757d24bd90
14 changed files with 368 additions and 35 deletions
+24
View File
@@ -152,6 +152,30 @@ pub async fn run_agentic_loop(
// Call LLM // Call LLM
let output = delegate.call_llm(reasoning, reason_ctx, iteration).await?; let output = delegate.call_llm(reasoning, reason_ctx, iteration).await?;
match &output.result {
RespondResult::Text(text) => {
tracing::debug!(
iteration,
len = text.len(),
has_suggestions = text.contains("<suggestions>"),
response = %text,
"LLM text response"
);
}
RespondResult::ToolCalls {
tool_calls,
content,
} => {
let names: Vec<&str> = tool_calls.iter().map(|tc| tc.name.as_str()).collect();
tracing::debug!(
iteration,
tools = ?names,
has_content = content.is_some(),
"LLM tool_calls response"
);
}
}
match output.result { match output.result {
RespondResult::Text(text) => { RespondResult::Text(text) => {
// Tool intent nudge: if the LLM says "let me search..." without // Tool intent nudge: if the LLM says "let me search..." without
+97
View File
@@ -1051,6 +1051,54 @@ fn strip_internal_tool_call_text(text: &str) -> String {
} }
} }
/// Extract `<suggestions>["...","..."]</suggestions>` from a response string.
///
/// Returns `(cleaned_text, suggestions)`. The `<suggestions>` block is stripped
/// from the text regardless of whether the JSON inside parses successfully.
/// Only the **last** `<suggestions>` block is used (closest to end of response).
/// Blocks inside markdown code fences are ignored.
pub(crate) fn extract_suggestions(text: &str) -> (String, Vec<String>) {
use regex::Regex;
use std::sync::LazyLock;
static RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?s)<suggestions>\s*(.*?)\s*</suggestions>").expect("valid regex") // safety: constant pattern
});
// Find the position of the last closing code fence to avoid matching inside code blocks
let last_code_fence = text.rfind("```").unwrap_or(0);
// Find all matches, take the last one that's after the last code fence
let mut best_match: Option<regex::Match<'_>> = None;
let mut best_capture: Option<String> = None;
for caps in RE.captures_iter(text) {
if let (Some(full), Some(inner)) = (caps.get(0), caps.get(1))
&& full.start() >= last_code_fence
{
best_match = Some(full);
best_capture = Some(inner.as_str().to_string());
}
}
let Some(full) = best_match else {
return (text.to_string(), Vec::new());
};
let cleaned = format!("{}{}", &text[..full.start()], &text[full.end()..]); // safety: regex match boundaries are valid UTF-8
let cleaned = cleaned.trim().to_string();
// Parse the JSON array
let suggestions = best_capture
.and_then(|json| serde_json::from_str::<Vec<String>>(&json).ok())
.unwrap_or_default()
.into_iter()
.filter(|s| !s.trim().is_empty() && s.len() <= 80)
.take(3)
.collect();
(cleaned, suggestions)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::sync::Arc; use std::sync::Arc;
@@ -2197,6 +2245,55 @@ mod tests {
assert_eq!(result, input); assert_eq!(result, input);
} }
#[test]
fn test_extract_suggestions_basic() {
let input = "Here is my answer.\n<suggestions>[\"Check logs\", \"Deploy\"]</suggestions>";
let (text, suggestions) = super::extract_suggestions(input);
assert_eq!(text, "Here is my answer."); // safety: test
assert_eq!(suggestions, vec!["Check logs", "Deploy"]); // safety: test
}
#[test]
fn test_extract_suggestions_no_tag() {
let input = "Just a plain response.";
let (text, suggestions) = super::extract_suggestions(input);
assert_eq!(text, "Just a plain response."); // safety: test
assert!(suggestions.is_empty()); // safety: test
}
#[test]
fn test_extract_suggestions_malformed_json() {
let input = "Answer.\n<suggestions>not json</suggestions>";
let (text, suggestions) = super::extract_suggestions(input);
assert_eq!(text, "Answer."); // safety: test
assert!(suggestions.is_empty()); // safety: test
}
#[test]
fn test_extract_suggestions_inside_code_fence() {
let input = "```\n<suggestions>[\"foo\"]</suggestions>\n```";
let (text, suggestions) = super::extract_suggestions(input);
// The tag is inside a code fence, so it should not be extracted
assert_eq!(text, input); // safety: test
assert!(suggestions.is_empty()); // safety: test
}
#[test]
fn test_extract_suggestions_after_code_fence() {
let input = "```\ncode\n```\nAnswer.\n<suggestions>[\"foo\"]</suggestions>";
let (text, suggestions) = super::extract_suggestions(input);
assert_eq!(text, "```\ncode\n```\nAnswer."); // safety: test
assert_eq!(suggestions, vec!["foo"]); // safety: test
}
#[test]
fn test_extract_suggestions_filters_long() {
let long = "x".repeat(81);
let input = format!("Answer.\n<suggestions>[\"{}\", \"ok\"]</suggestions>", long);
let (_, suggestions) = super::extract_suggestions(&input);
assert_eq!(suggestions, vec!["ok"]); // safety: test
}
#[test] #[test]
fn test_tool_error_format_includes_tool_name() { fn test_tool_error_format_includes_tool_name() {
// Regression test for issue #487: tool errors sent to the LLM should // Regression test for issue #487: tool errors sent to the LLM should
+28
View File
@@ -420,6 +420,10 @@ impl Agent {
// Complete, fail, or request approval // Complete, fail, or request approval
match result { match result {
Ok(AgenticLoopResult::Response(response)) => { Ok(AgenticLoopResult::Response(response)) => {
// Extract <suggestions> from response text before user sees it
let (response, suggestions) =
crate::agent::dispatcher::extract_suggestions(&response);
// Hook: TransformResponse — allow hooks to modify or reject the final response // Hook: TransformResponse — allow hooks to modify or reject the final response
let response = { let response = {
let event = crate::hooks::HookEvent::ResponseTransform { let event = crate::hooks::HookEvent::ResponseTransform {
@@ -473,6 +477,18 @@ impl Agent {
) )
.await; .await;
// Send suggestions after response (best-effort, rendered by web gateway)
if !suggestions.is_empty() {
let _ = self
.channels
.send_status(
&message.channel,
StatusUpdate::Suggestions { suggestions },
&message.metadata,
)
.await;
}
Ok(SubmissionResult::response(response)) Ok(SubmissionResult::response(response))
} }
Ok(AgenticLoopResult::NeedApproval { pending }) => { Ok(AgenticLoopResult::NeedApproval { pending }) => {
@@ -1334,6 +1350,8 @@ impl Agent {
match result { match result {
Ok(AgenticLoopResult::Response(response)) => { Ok(AgenticLoopResult::Response(response)) => {
let (response, suggestions) =
crate::agent::dispatcher::extract_suggestions(&response);
thread.complete_turn(&response); thread.complete_turn(&response);
let (turn_number, tool_calls) = thread let (turn_number, tool_calls) = thread
.turns .turns
@@ -1364,6 +1382,16 @@ impl Agent {
&message.metadata, &message.metadata,
) )
.await; .await;
if !suggestions.is_empty() {
let _ = self
.channels
.send_status(
&message.channel,
StatusUpdate::Suggestions { suggestions },
&message.metadata,
)
.await;
}
Ok(SubmissionResult::response(response)) Ok(SubmissionResult::response(response))
} }
Ok(AgenticLoopResult::NeedApproval { Ok(AgenticLoopResult::NeedApproval {
+2
View File
@@ -238,6 +238,8 @@ pub enum StatusUpdate {
/// Optional workspace path where the image was saved. /// Optional workspace path where the image was saved.
path: Option<String>, path: Option<String>,
}, },
/// Suggested follow-up messages for the user.
Suggestions { suggestions: Vec<String> },
} }
impl StatusUpdate { impl StatusUpdate {
+3
View File
@@ -607,6 +607,9 @@ impl Channel for ReplChannel {
eprintln!("\x1b[36m [image generated]\x1b[0m"); eprintln!("\x1b[36m [image generated]\x1b[0m");
} }
} }
StatusUpdate::Suggestions { .. } => {
// Suggestions are only rendered by the web gateway
}
} }
Ok(()) Ok(())
} }
+52 -24
View File
@@ -1664,7 +1664,9 @@ impl WasmChannel {
.await; .await;
let pairing_store = self.pairing_store.clone(); let pairing_store = self.pairing_store.clone();
let wit_update = status_to_wit(status, metadata); let Some(wit_update) = status_to_wit(status, metadata) else {
return Ok(());
};
let result = tokio::time::timeout(timeout, async move { let result = tokio::time::timeout(timeout, async move {
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
@@ -1833,7 +1835,9 @@ impl WasmChannel {
.await; .await;
let pairing_store = self.pairing_store.clone(); let pairing_store = self.pairing_store.clone();
let callback_timeout = self.runtime.config().callback_timeout; let callback_timeout = self.runtime.config().callback_timeout;
let wit_update = status_to_wit(&status, metadata); let Some(wit_update) = status_to_wit(&status, metadata) else {
return Ok(());
};
let handle = tokio::spawn(async move { let handle = tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(4)); let mut interval = tokio::time::interval(Duration::from_secs(4));
@@ -2704,10 +2708,13 @@ fn truncate_status_text(input: &str, max_chars: usize) -> String {
} }
} }
fn status_to_wit(status: &StatusUpdate, metadata: &serde_json::Value) -> wit_channel::StatusUpdate { fn status_to_wit(
status: &StatusUpdate,
metadata: &serde_json::Value,
) -> Option<wit_channel::StatusUpdate> {
let metadata_json = serde_json::to_string(metadata).unwrap_or_default(); let metadata_json = serde_json::to_string(metadata).unwrap_or_default();
match status { Some(match status {
StatusUpdate::Thinking(msg) => wit_channel::StatusUpdate { StatusUpdate::Thinking(msg) => wit_channel::StatusUpdate {
status: wit_channel::StatusType::Thinking, status: wit_channel::StatusType::Thinking,
message: msg.clone(), message: msg.clone(),
@@ -2827,7 +2834,9 @@ fn status_to_wit(status: &StatusUpdate, metadata: &serde_json::Value) -> wit_cha
}, },
metadata_json, metadata_json,
}, },
} // Suggestions are web-gateway-only; skip for WASM channels
StatusUpdate::Suggestions { .. } => return None,
})
} }
/// Clone a WIT StatusUpdate (the generated type doesn't derive Clone). /// Clone a WIT StatusUpdate (the generated type doesn't derive Clone).
@@ -3556,7 +3565,8 @@ mod tests {
let wit = status_to_wit( let wit = status_to_wit(
&crate::channels::StatusUpdate::Thinking("Processing...".into()), &crate::channels::StatusUpdate::Thinking("Processing...".into()),
&metadata, &metadata,
); )
.unwrap(); // safety: test
assert!(matches!( assert!(matches!(
wit.status, wit.status,
@@ -3574,7 +3584,8 @@ mod tests {
let wit = status_to_wit( let wit = status_to_wit(
&crate::channels::StatusUpdate::Status("Done".into()), &crate::channels::StatusUpdate::Status("Done".into()),
&metadata, &metadata,
); )
.unwrap(); // safety: test
assert!(matches!(wit.status, super::wit_channel::StatusType::Done)); assert!(matches!(wit.status, super::wit_channel::StatusType::Done));
} }
@@ -3589,14 +3600,16 @@ mod tests {
let wit = status_to_wit( let wit = status_to_wit(
&crate::channels::StatusUpdate::Status("done".into()), &crate::channels::StatusUpdate::Status("done".into()),
&metadata, &metadata,
); )
.unwrap(); // safety: test
assert!(matches!(wit.status, super::wit_channel::StatusType::Done)); assert!(matches!(wit.status, super::wit_channel::StatusType::Done));
// with whitespace // with whitespace
let wit = status_to_wit( let wit = status_to_wit(
&crate::channels::StatusUpdate::Status(" Done ".into()), &crate::channels::StatusUpdate::Status(" Done ".into()),
&metadata, &metadata,
); )
.unwrap(); // safety: test
assert!(matches!(wit.status, super::wit_channel::StatusType::Done)); assert!(matches!(wit.status, super::wit_channel::StatusType::Done));
} }
@@ -3608,7 +3621,8 @@ mod tests {
let wit = status_to_wit( let wit = status_to_wit(
&crate::channels::StatusUpdate::Status("Interrupted".into()), &crate::channels::StatusUpdate::Status("Interrupted".into()),
&metadata, &metadata,
); )
.unwrap(); // safety: test
assert!(matches!( assert!(matches!(
wit.status, wit.status,
@@ -3626,7 +3640,8 @@ mod tests {
let wit = status_to_wit( let wit = status_to_wit(
&crate::channels::StatusUpdate::Status("interrupted".into()), &crate::channels::StatusUpdate::Status("interrupted".into()),
&metadata, &metadata,
); )
.unwrap(); // safety: test
assert!(matches!( assert!(matches!(
wit.status, wit.status,
super::wit_channel::StatusType::Interrupted super::wit_channel::StatusType::Interrupted
@@ -3636,7 +3651,8 @@ mod tests {
let wit = status_to_wit( let wit = status_to_wit(
&crate::channels::StatusUpdate::Status(" Interrupted ".into()), &crate::channels::StatusUpdate::Status(" Interrupted ".into()),
&metadata, &metadata,
); )
.unwrap(); // safety: test
assert!(matches!( assert!(matches!(
wit.status, wit.status,
super::wit_channel::StatusType::Interrupted super::wit_channel::StatusType::Interrupted
@@ -3651,7 +3667,8 @@ mod tests {
let wit = status_to_wit( let wit = status_to_wit(
&crate::channels::StatusUpdate::Status("Awaiting approval".into()), &crate::channels::StatusUpdate::Status("Awaiting approval".into()),
&metadata, &metadata,
); )
.unwrap(); // safety: test
assert!(matches!(wit.status, super::wit_channel::StatusType::Status)); assert!(matches!(wit.status, super::wit_channel::StatusType::Status));
assert_eq!(wit.message, "Awaiting approval"); assert_eq!(wit.message, "Awaiting approval");
@@ -3670,7 +3687,8 @@ mod tests {
setup_url: None, setup_url: None,
}, },
&metadata, &metadata,
); )
.unwrap(); // safety: test
assert!(matches!( assert!(matches!(
wit.status, wit.status,
@@ -3690,7 +3708,8 @@ mod tests {
name: "http_request".to_string(), name: "http_request".to_string(),
}, },
&metadata, &metadata,
); )
.unwrap(); // safety: test
assert!(matches!( assert!(matches!(
wit.status, wit.status,
@@ -3712,7 +3731,8 @@ mod tests {
parameters: None, parameters: None,
}, },
&metadata, &metadata,
); )
.unwrap(); // safety: test
assert!(matches!( assert!(matches!(
wit.status, wit.status,
@@ -3734,7 +3754,8 @@ mod tests {
parameters: None, parameters: None,
}, },
&metadata, &metadata,
); )
.unwrap(); // safety: test
assert!(matches!( assert!(matches!(
wit.status, wit.status,
@@ -3754,7 +3775,8 @@ mod tests {
preview: "{".to_string() + "\"temperature\": 22}", preview: "{".to_string() + "\"temperature\": 22}",
}, },
&metadata, &metadata,
); )
.unwrap(); // safety: test
assert!(matches!( assert!(matches!(
wit.status, wit.status,
@@ -3775,7 +3797,8 @@ mod tests {
preview: long_preview, preview: long_preview,
}, },
&metadata, &metadata,
); )
.unwrap(); // safety: test
assert!(matches!( assert!(matches!(
wit.status, wit.status,
@@ -3796,7 +3819,8 @@ mod tests {
browse_url: "https://example.com/jobs/job-1".to_string(), browse_url: "https://example.com/jobs/job-1".to_string(),
}, },
&metadata, &metadata,
); )
.unwrap(); // safety: test
assert!(matches!( assert!(matches!(
wit.status, wit.status,
@@ -3818,7 +3842,8 @@ mod tests {
message: "Token saved".to_string(), message: "Token saved".to_string(),
}, },
&metadata, &metadata,
); )
.unwrap(); // safety: test
assert!(matches!( assert!(matches!(
wit.status, wit.status,
@@ -3840,7 +3865,8 @@ mod tests {
message: "Invalid token".to_string(), message: "Invalid token".to_string(),
}, },
&metadata, &metadata,
); )
.unwrap(); // safety: test
assert!(matches!( assert!(matches!(
wit.status, wit.status,
@@ -3863,7 +3889,8 @@ mod tests {
parameters: serde_json::json!({"url": "https://api.weather.test"}), parameters: serde_json::json!({"url": "https://api.weather.test"}),
}, },
&metadata, &metadata,
); )
.unwrap(); // safety: test
assert!(matches!( assert!(matches!(
wit.status, wit.status,
@@ -3887,7 +3914,8 @@ mod tests {
parameters: serde_json::json!({"url": "https://api.weather.test"}), parameters: serde_json::json!({"url": "https://api.weather.test"}),
}, },
&metadata, &metadata,
); )
.unwrap(); // safety: test
assert!(matches!( assert!(matches!(
wit.status, wit.status,
+4
View File
@@ -397,6 +397,10 @@ impl Channel for GatewayChannel {
StatusUpdate::ImageGenerated { data_url, path } => SseEvent::ImageGenerated { StatusUpdate::ImageGenerated { data_url, path } => SseEvent::ImageGenerated {
data_url, data_url,
path, path,
thread_id: thread_id.clone(),
},
StatusUpdate::Suggestions { suggestions } => SseEvent::Suggestions {
suggestions,
thread_id, thread_id,
}, },
}; };
+1
View File
@@ -143,6 +143,7 @@ impl SseManager {
SseEvent::JobResult { .. } => "job_result", SseEvent::JobResult { .. } => "job_result",
SseEvent::Heartbeat => "heartbeat", SseEvent::Heartbeat => "heartbeat",
SseEvent::ImageGenerated { .. } => "image_generated", SseEvent::ImageGenerated { .. } => "image_generated",
SseEvent::Suggestions { .. } => "suggestions",
SseEvent::ExtensionStatus { .. } => "extension_status", SseEvent::ExtensionStatus { .. } => "extension_status",
}; };
Ok(Event::default().event(event_type).data(data)) Ok(Event::default().event(event_type).data(data))
+81
View File
@@ -19,6 +19,7 @@ let _loadThreadsTimer = null;
const JOB_EVENTS_CAP = 500; const JOB_EVENTS_CAP = 500;
const MEMORY_SEARCH_QUERY_MAX_LENGTH = 100; const MEMORY_SEARCH_QUERY_MAX_LENGTH = 100;
let stagedImages = []; let stagedImages = [];
let _ghostSuggestion = '';
// --- Slash Commands --- // --- Slash Commands ---
@@ -286,9 +287,18 @@ function connectSSE() {
if (data.thread_id) debouncedLoadThreads(); if (data.thread_id) debouncedLoadThreads();
return; return;
} }
clearSuggestionChips();
showActivityThinking(data.message); showActivityThinking(data.message);
}); });
eventSource.addEventListener('suggestions', (e) => {
const data = JSON.parse(e.data);
if (!isCurrentThread(data.thread_id)) return;
if (data.suggestions && data.suggestions.length > 0) {
showSuggestionChips(data.suggestions);
}
});
eventSource.addEventListener('tool_started', (e) => { eventSource.addEventListener('tool_started', (e) => {
const data = JSON.parse(e.data); const data = JSON.parse(e.data);
if (!isCurrentThread(data.thread_id)) return; if (!isCurrentThread(data.thread_id)) return;
@@ -423,9 +433,59 @@ function isCurrentThread(threadId) {
return threadId === currentThreadId; return threadId === currentThreadId;
} }
// --- Suggestion Chips ---
function showSuggestionChips(suggestions) {
// Clear previous chips/ghost without restoring placeholder (we'll set it below)
_ghostSuggestion = '';
const container = document.getElementById('suggestion-chips');
container.innerHTML = '';
const ghost = document.getElementById('ghost-text');
ghost.style.display = 'none';
const wrapper = document.querySelector('.chat-input-wrapper');
if (wrapper) wrapper.classList.remove('has-ghost');
_ghostSuggestion = suggestions[0] || '';
const input = document.getElementById('chat-input');
suggestions.forEach(text => {
const chip = document.createElement('button');
chip.className = 'suggestion-chip';
chip.textContent = text;
chip.addEventListener('click', () => {
input.value = text;
clearSuggestionChips();
autoResizeTextarea(input);
input.focus();
sendMessage();
});
container.appendChild(chip);
});
container.style.display = 'flex';
// Show first suggestion as ghost text in the input so user knows Tab works
if (_ghostSuggestion && input.value === '') {
ghost.textContent = _ghostSuggestion;
ghost.style.display = 'block';
input.closest('.chat-input-wrapper').classList.add('has-ghost');
}
}
function clearSuggestionChips() {
_ghostSuggestion = '';
const container = document.getElementById('suggestion-chips');
if (container) {
container.innerHTML = '';
container.style.display = 'none';
}
const ghost = document.getElementById('ghost-text');
if (ghost) ghost.style.display = 'none';
const wrapper = document.querySelector('.chat-input-wrapper');
if (wrapper) wrapper.classList.remove('has-ghost');
}
// --- Chat --- // --- Chat ---
function sendMessage() { function sendMessage() {
clearSuggestionChips();
const input = document.getElementById('chat-input'); const input = document.getElementById('chat-input');
if (!currentThreadId) { if (!currentThreadId) {
console.warn('sendMessage: no thread selected, ignoring'); console.warn('sendMessage: no thread selected, ignoring');
@@ -1334,6 +1394,7 @@ function showAuthCardError(extensionName, message) {
} }
function loadHistory(before) { function loadHistory(before) {
clearSuggestionChips();
let historyUrl = '/api/chat/history?limit=50'; let historyUrl = '/api/chat/history?limit=50';
if (currentThreadId) { if (currentThreadId) {
historyUrl += '&thread_id=' + encodeURIComponent(currentThreadId); historyUrl += '&thread_id=' + encodeURIComponent(currentThreadId);
@@ -1629,6 +1690,7 @@ function switchToAssistant() {
} }
function switchThread(threadId) { function switchThread(threadId) {
clearSuggestionChips();
finalizeActivityGroup(); finalizeActivityGroup();
currentThreadId = threadId; currentThreadId = threadId;
unreadThreads.delete(threadId); unreadThreads.delete(threadId);
@@ -1661,6 +1723,15 @@ chatInput.addEventListener('keydown', (e) => {
const acEl = document.getElementById('slash-autocomplete'); const acEl = document.getElementById('slash-autocomplete');
const acVisible = acEl && acEl.style.display !== 'none'; const acVisible = acEl && acEl.style.display !== 'none';
// Accept first suggestion with Tab (plain Tab only, not Shift+Tab)
if (e.key === 'Tab' && !e.shiftKey && !acVisible && _ghostSuggestion && chatInput.value === '') {
e.preventDefault();
chatInput.value = _ghostSuggestion;
clearSuggestionChips();
autoResizeTextarea(chatInput);
return;
}
if (acVisible) { if (acVisible) {
const items = acEl.querySelectorAll('.slash-ac-item'); const items = acEl.querySelectorAll('.slash-ac-item');
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
@@ -1697,6 +1768,16 @@ chatInput.addEventListener('keydown', (e) => {
chatInput.addEventListener('input', () => { chatInput.addEventListener('input', () => {
autoResizeTextarea(chatInput); autoResizeTextarea(chatInput);
filterSlashCommands(chatInput.value); filterSlashCommands(chatInput.value);
const ghost = document.getElementById('ghost-text');
const wrapper = chatInput.closest('.chat-input-wrapper');
if (chatInput.value !== '') {
ghost.style.display = 'none';
wrapper.classList.remove('has-ghost');
} else if (_ghostSuggestion) {
ghost.textContent = _ghostSuggestion;
ghost.style.display = 'block';
wrapper.classList.add('has-ghost');
}
}); });
chatInput.addEventListener('blur', () => { chatInput.addEventListener('blur', () => {
// Small delay so mousedown on autocomplete item fires first // Small delay so mousedown on autocomplete item fires first
+5 -1
View File
@@ -155,9 +155,13 @@
<div class="chat-container"> <div class="chat-container">
<div class="chat-messages" id="chat-messages"></div> <div class="chat-messages" id="chat-messages"></div>
<div id="slash-autocomplete" class="slash-autocomplete" style="display:none"></div> <div id="slash-autocomplete" class="slash-autocomplete" style="display:none"></div>
<div id="suggestion-chips" class="suggestion-chips" style="display:none"></div>
<div class="chat-input"> <div class="chat-input">
<div id="image-preview-strip" class="image-preview-strip"></div> <div id="image-preview-strip" class="image-preview-strip"></div>
<textarea id="chat-input" data-i18n="chat.inputPlaceholder" data-i18n-attr="placeholder" placeholder="Message or / for commands..." rows="1"></textarea> <div class="chat-input-wrapper">
<textarea id="chat-input" data-i18n="chat.inputPlaceholder" data-i18n-attr="placeholder" placeholder="Message or / for commands..." rows="1"></textarea>
<div id="ghost-text" class="ghost-text"></div>
</div>
<input type="file" id="image-file-input" accept="image/*" multiple style="display:none"> <input type="file" id="image-file-input" accept="image/*" multiple style="display:none">
<button id="attach-btn" class="attach-btn" data-i18n="chat.attachImages" data-i18n-attr="title" title="Attach images" <button id="attach-btn" class="attach-btn" data-i18n="chat.attachImages" data-i18n-attr="title" title="Attach images"
aria-label="Attach images">&#x1F4CE;</button> aria-label="Attach images">&#x1F4CE;</button>
+60 -5
View File
@@ -1362,8 +1362,14 @@ body {
min-height: 56px; min-height: 56px;
} }
.chat-input textarea { .chat-input-wrapper {
position: relative;
flex: 1; flex: 1;
display: flex;
}
.chat-input-wrapper textarea {
width: 100%;
padding: 8px 12px; padding: 8px 12px;
background: var(--bg); background: var(--bg);
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -1376,17 +1382,66 @@ body {
max-height: 120px; max-height: 120px;
} }
.chat-input textarea:focus { .ghost-text {
position: absolute;
top: 0;
left: 0;
right: 0;
padding: 8px 12px;
font-size: 14px;
font-family: inherit;
color: var(--text-secondary);
opacity: 0.5;
pointer-events: none;
white-space: pre-wrap;
overflow: hidden;
display: none;
z-index: 1;
}
/* Hide native placeholder when ghost text is visible */
.chat-input-wrapper.has-ghost textarea::placeholder {
color: transparent;
}
.chat-input-wrapper textarea:focus {
outline: none; outline: none;
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1); box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
} }
.chat-input textarea:disabled { .chat-input-wrapper textarea:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
.suggestion-chips {
display: none;
flex-wrap: wrap;
gap: 8px;
padding: 8px 16px;
border-top: 1px solid var(--border);
}
.suggestion-chip {
padding: 6px 14px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 16px;
color: var(--text-secondary);
font-size: 13px;
font-family: inherit;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.suggestion-chip:hover {
background: var(--accent);
color: #09090b;
border-color: var(--accent);
}
.chat-input button { .chat-input button {
padding: 8px 20px; padding: 8px 20px;
background: var(--accent); background: var(--accent);
@@ -1416,7 +1471,7 @@ body {
} }
/* Keyboard accessibility focus rings */ /* Keyboard accessibility focus rings */
.chat-input textarea:focus-visible, .chat-input-wrapper textarea:focus-visible,
.chat-input button:focus-visible, .chat-input button:focus-visible,
.tab-bar button:focus-visible, .tab-bar button:focus-visible,
.tree-row:focus-visible { .tree-row:focus-visible {
@@ -3824,7 +3879,7 @@ mark {
min-height: 52px; min-height: 52px;
} }
.chat-input textarea { .chat-input-wrapper textarea {
min-height: 36px; min-height: 36px;
max-height: 100px; max-height: 100px;
} }
+9
View File
@@ -242,6 +242,14 @@ pub enum SseEvent {
thread_id: Option<String>, thread_id: Option<String>,
}, },
/// Suggested follow-up messages for the user.
#[serde(rename = "suggestions")]
Suggestions {
suggestions: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
/// Extension activation status change (WASM channels). /// Extension activation status change (WASM channels).
#[serde(rename = "extension_status")] #[serde(rename = "extension_status")]
ExtensionStatus { ExtensionStatus {
@@ -707,6 +715,7 @@ impl WsServerMessage {
SseEvent::JobStatus { .. } => "job_status", SseEvent::JobStatus { .. } => "job_status",
SseEvent::JobResult { .. } => "job_result", SseEvent::JobResult { .. } => "job_result",
SseEvent::ImageGenerated { .. } => "image_generated", SseEvent::ImageGenerated { .. } => "image_generated",
SseEvent::Suggestions { .. } => "suggestions",
SseEvent::ExtensionStatus { .. } => "extension_status", SseEvent::ExtensionStatus { .. } => "extension_status",
}; };
let data = serde_json::to_value(event).unwrap_or(serde_json::Value::Null); let data = serde_json::to_value(event).unwrap_or(serde_json::Value::Null);
-4
View File
@@ -270,10 +270,6 @@ impl NearAiChatProvider {
reason: format!("Failed to read response body: {}", e), reason: format!("Failed to read response body: {}", e),
})?; })?;
if tracing::enabled!(tracing::Level::DEBUG) {
tracing::debug!("NEAR AI Chat response status: {}", status);
}
// Log response body only at TRACE level to avoid exposing sensitive content // Log response body only at TRACE level to avoid exposing sensitive content
// (user-generated data, tool outputs, leaked secrets) in DEBUG logs // (user-generated data, tool outputs, leaked secrets) in DEBUG logs
if tracing::enabled!(tracing::Level::TRACE) { if tracing::enabled!(tracing::Level::TRACE) {
+2 -1
View File
@@ -902,7 +902,8 @@ Example:
## Guidelines ## Guidelines
- Be concise and direct - Be concise and direct
- Use markdown formatting where helpful - Use markdown formatting where helpful
- For code, use appropriate code blocks with language tags{} - For code, use appropriate code blocks with language tags
- ALWAYS end your response with a <suggestions> tag containing a JSON array of 1-3 short follow-up commands. Each suggestion must read as something the USER would type to instruct YOU. Write them in the user's voice as direct commands, not as requests FROM you TO the user. Do NOT repeat or rephrase content already in your response. Example: <suggestions>["Suggest dinner spots in my area", "Find a quick recipe for pasta"]</suggestions> Keep each under 80 characters.{}
## Safety ## Safety
- You have no independent goals. Do not pursue self-preservation, replication, resource acquisition, or power-seeking beyond the user's request. - You have no independent goals. Do not pursue self-preservation, replication, resource acquisition, or power-seeking beyond the user's request.