From 6a55c466831274251736e2a272c547f90fa5780e Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Fri, 13 Mar 2026 16:26:30 -0700 Subject: [PATCH 1/2] 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 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) --- src/agent/agentic_loop.rs | 21 +++++++ src/agent/dispatcher.rs | 96 ++++++++++++++++++++++++++++++ src/agent/thread_ops.rs | 16 +++++ src/channels/channel.rs | 2 + src/channels/repl.rs | 3 + src/channels/wasm/wrapper.rs | 5 ++ src/channels/web/mod.rs | 4 ++ src/channels/web/sse.rs | 1 + src/channels/web/static/app.js | 79 ++++++++++++++++++++++++ src/channels/web/static/index.html | 6 +- src/channels/web/static/style.css | 65 ++++++++++++++++++-- src/channels/web/types.rs | 9 +++ src/llm/nearai_chat.rs | 4 -- src/llm/reasoning.rs | 9 ++- 14 files changed, 309 insertions(+), 11 deletions(-) diff --git a/src/agent/agentic_loop.rs b/src/agent/agentic_loop.rs index 0e5bef9d6f5..a8b847dfea8 100644 --- a/src/agent/agentic_loop.rs +++ b/src/agent/agentic_loop.rs @@ -152,6 +152,27 @@ pub async fn run_agentic_loop( // Call LLM 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(""), + 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 { RespondResult::Text(text) => { // Tool intent nudge: if the LLM says "let me search..." without diff --git a/src/agent/dispatcher.rs b/src/agent/dispatcher.rs index d0ae98ad851..64cd820ba99 100644 --- a/src/agent/dispatcher.rs +++ b/src/agent/dispatcher.rs @@ -1051,6 +1051,53 @@ fn strip_internal_tool_call_text(text: &str) -> String { } } +/// Extract `["...","..."]` from a response string. +/// +/// Returns `(cleaned_text, suggestions)`. The `` block is stripped +/// from the text regardless of whether the JSON inside parses successfully. +/// Only the **last** `` block is used (closest to end of response). +/// Blocks inside markdown code fences are ignored. +pub(crate) fn extract_suggestions(text: &str) -> (String, Vec) { + use regex::Regex; + use std::sync::LazyLock; + + static RE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?s)\s*(.*?)\s*").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> = None; + let mut best_capture: Option = 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::>(&json).ok()) + .unwrap_or_default() + .into_iter() + .filter(|s| !s.is_empty() && s.len() <= 80) + .collect(); + + (cleaned, suggestions) +} + #[cfg(test)] mod tests { use std::sync::Arc; @@ -2197,6 +2244,55 @@ mod tests { assert_eq!(result, input); } + #[test] + fn test_extract_suggestions_basic() { + let input = "Here is my answer.\n[\"Check logs\", \"Deploy\"]"; + 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.\nnot json"; + 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[\"foo\"]\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[\"foo\"]"; + 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[\"{}\", \"ok\"]", long); + let (_, suggestions) = super::extract_suggestions(&input); + assert_eq!(suggestions, vec!["ok"]); // safety: test + } + #[test] fn test_tool_error_format_includes_tool_name() { // Regression test for issue #487: tool errors sent to the LLM should diff --git a/src/agent/thread_ops.rs b/src/agent/thread_ops.rs index f3673781196..5a9b1052755 100644 --- a/src/agent/thread_ops.rs +++ b/src/agent/thread_ops.rs @@ -420,6 +420,10 @@ impl Agent { // Complete, fail, or request approval match result { Ok(AgenticLoopResult::Response(response)) => { + // Extract 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 let response = { let event = crate::hooks::HookEvent::ResponseTransform { @@ -473,6 +477,18 @@ impl Agent { ) .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(AgenticLoopResult::NeedApproval { pending }) => { diff --git a/src/channels/channel.rs b/src/channels/channel.rs index 938b1f4f37c..1fc76fd74ff 100644 --- a/src/channels/channel.rs +++ b/src/channels/channel.rs @@ -238,6 +238,8 @@ pub enum StatusUpdate { /// Optional workspace path where the image was saved. path: Option, }, + /// Suggested follow-up messages for the user. + Suggestions { suggestions: Vec }, } impl StatusUpdate { diff --git a/src/channels/repl.rs b/src/channels/repl.rs index 33adc23f62d..230d5e92c25 100644 --- a/src/channels/repl.rs +++ b/src/channels/repl.rs @@ -607,6 +607,9 @@ impl Channel for ReplChannel { eprintln!("\x1b[36m [image generated]\x1b[0m"); } } + StatusUpdate::Suggestions { .. } => { + // Suggestions are only rendered by the web gateway + } } Ok(()) } diff --git a/src/channels/wasm/wrapper.rs b/src/channels/wasm/wrapper.rs index 914ffbf0092..404b6cff445 100644 --- a/src/channels/wasm/wrapper.rs +++ b/src/channels/wasm/wrapper.rs @@ -2827,6 +2827,11 @@ fn status_to_wit(status: &StatusUpdate, metadata: &serde_json::Value) -> wit_cha }, metadata_json, }, + StatusUpdate::Suggestions { .. } => wit_channel::StatusUpdate { + status: wit_channel::StatusType::Status, + message: String::new(), + metadata_json, + }, } } diff --git a/src/channels/web/mod.rs b/src/channels/web/mod.rs index 4c575caf70e..0d970569a9e 100644 --- a/src/channels/web/mod.rs +++ b/src/channels/web/mod.rs @@ -397,6 +397,10 @@ impl Channel for GatewayChannel { StatusUpdate::ImageGenerated { data_url, path } => SseEvent::ImageGenerated { data_url, path, + thread_id: thread_id.clone(), + }, + StatusUpdate::Suggestions { suggestions } => SseEvent::Suggestions { + suggestions, thread_id, }, }; diff --git a/src/channels/web/sse.rs b/src/channels/web/sse.rs index 6d9c4142da0..306576b9f53 100644 --- a/src/channels/web/sse.rs +++ b/src/channels/web/sse.rs @@ -143,6 +143,7 @@ impl SseManager { SseEvent::JobResult { .. } => "job_result", SseEvent::Heartbeat => "heartbeat", SseEvent::ImageGenerated { .. } => "image_generated", + SseEvent::Suggestions { .. } => "suggestions", SseEvent::ExtensionStatus { .. } => "extension_status", }; Ok(Event::default().event(event_type).data(data)) diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index 081ae60d9d0..e1b87f3c280 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -19,6 +19,7 @@ let _loadThreadsTimer = null; const JOB_EVENTS_CAP = 500; const MEMORY_SEARCH_QUERY_MAX_LENGTH = 100; let stagedImages = []; +let _ghostSuggestion = ''; // --- Slash Commands --- @@ -286,9 +287,18 @@ function connectSSE() { if (data.thread_id) debouncedLoadThreads(); return; } + clearSuggestionChips(); 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) => { const data = JSON.parse(e.data); if (!isCurrentThread(data.thread_id)) return; @@ -423,9 +433,57 @@ function isCurrentThread(threadId) { 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'; + + _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 --- function sendMessage() { + clearSuggestionChips(); const input = document.getElementById('chat-input'); if (!currentThreadId) { console.warn('sendMessage: no thread selected, ignoring'); @@ -1334,6 +1392,7 @@ function showAuthCardError(extensionName, message) { } function loadHistory(before) { + clearSuggestionChips(); let historyUrl = '/api/chat/history?limit=50'; if (currentThreadId) { historyUrl += '&thread_id=' + encodeURIComponent(currentThreadId); @@ -1629,6 +1688,7 @@ function switchToAssistant() { } function switchThread(threadId) { + clearSuggestionChips(); finalizeActivityGroup(); currentThreadId = threadId; unreadThreads.delete(threadId); @@ -1661,6 +1721,15 @@ chatInput.addEventListener('keydown', (e) => { const acEl = document.getElementById('slash-autocomplete'); const acVisible = acEl && acEl.style.display !== 'none'; + // Accept first suggestion with Tab + if (e.key === 'Tab' && !acVisible && _ghostSuggestion && chatInput.value === '') { + e.preventDefault(); + chatInput.value = _ghostSuggestion; + clearSuggestionChips(); + autoResizeTextarea(chatInput); + return; + } + if (acVisible) { const items = acEl.querySelectorAll('.slash-ac-item'); if (e.key === 'ArrowDown') { @@ -1697,6 +1766,16 @@ chatInput.addEventListener('keydown', (e) => { chatInput.addEventListener('input', () => { autoResizeTextarea(chatInput); 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', () => { // Small delay so mousedown on autocomplete item fires first diff --git a/src/channels/web/static/index.html b/src/channels/web/static/index.html index e0a4ae07988..4e1074d08e2 100644 --- a/src/channels/web/static/index.html +++ b/src/channels/web/static/index.html @@ -155,9 +155,13 @@

Restart IronClaw Instance

+
- +
+ +
+
diff --git a/src/channels/web/static/style.css b/src/channels/web/static/style.css index b6e1cbdf7ba..0ba5766f1d6 100644 --- a/src/channels/web/static/style.css +++ b/src/channels/web/static/style.css @@ -1362,8 +1362,14 @@ body { min-height: 56px; } -.chat-input textarea { +.chat-input-wrapper { + position: relative; flex: 1; + display: flex; +} + +.chat-input-wrapper textarea { + width: 100%; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); @@ -1376,17 +1382,66 @@ body { 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; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1); } -.chat-input textarea:disabled { +.chat-input-wrapper textarea:disabled { opacity: 0.5; 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 { padding: 8px 20px; background: var(--accent); @@ -1416,7 +1471,7 @@ body { } /* Keyboard accessibility focus rings */ -.chat-input textarea:focus-visible, +.chat-input-wrapper textarea:focus-visible, .chat-input button:focus-visible, .tab-bar button:focus-visible, .tree-row:focus-visible { @@ -3824,7 +3879,7 @@ mark { min-height: 52px; } - .chat-input textarea { + .chat-input-wrapper textarea { min-height: 36px; max-height: 100px; } diff --git a/src/channels/web/types.rs b/src/channels/web/types.rs index b2355959291..b8690b7850a 100644 --- a/src/channels/web/types.rs +++ b/src/channels/web/types.rs @@ -242,6 +242,14 @@ pub enum SseEvent { thread_id: Option, }, + /// Suggested follow-up messages for the user. + #[serde(rename = "suggestions")] + Suggestions { + suggestions: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + thread_id: Option, + }, + /// Extension activation status change (WASM channels). #[serde(rename = "extension_status")] ExtensionStatus { @@ -707,6 +715,7 @@ impl WsServerMessage { SseEvent::JobStatus { .. } => "job_status", SseEvent::JobResult { .. } => "job_result", SseEvent::ImageGenerated { .. } => "image_generated", + SseEvent::Suggestions { .. } => "suggestions", SseEvent::ExtensionStatus { .. } => "extension_status", }; let data = serde_json::to_value(event).unwrap_or(serde_json::Value::Null); diff --git a/src/llm/nearai_chat.rs b/src/llm/nearai_chat.rs index da99c080c70..0c0335bd3d4 100644 --- a/src/llm/nearai_chat.rs +++ b/src/llm/nearai_chat.rs @@ -270,10 +270,6 @@ impl NearAiChatProvider { 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 // (user-generated data, tool outputs, leaked secrets) in DEBUG logs if tracing::enabled!(tracing::Level::TRACE) { diff --git a/src/llm/reasoning.rs b/src/llm/reasoning.rs index 3a654fedcb9..00de63d41c1 100644 --- a/src/llm/reasoning.rs +++ b/src/llm/reasoning.rs @@ -902,7 +902,14 @@ Example: ## Guidelines - Be concise and direct - 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 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: \"Suggest Italian places nearby\", not \ +\"Tell me your preferences so I can help\". Never phrase suggestions as requests FROM you TO the user. \ +Do NOT repeat or rephrase content already in your response. \ +Example: [\"Suggest dinner spots in my area\", \"Find a quick recipe for pasta\"] \ +Keep each under 80 characters.{} ## Safety - You have no independent goals. Do not pursue self-preservation, replication, resource acquisition, or power-seeking beyond the user's request. From 6411abe731ebc3ca3e602a3a85ad8640a546a0f0 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Fri, 13 Mar 2026 18:12:21 -0700 Subject: [PATCH 2/2] 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) --- src/agent/agentic_loop.rs | 5 ++- src/agent/dispatcher.rs | 3 +- src/agent/thread_ops.rs | 12 +++++ src/channels/wasm/wrapper.rs | 81 ++++++++++++++++++++++------------ src/channels/web/static/app.js | 6 ++- src/llm/reasoning.rs | 8 +--- 6 files changed, 75 insertions(+), 40 deletions(-) diff --git a/src/agent/agentic_loop.rs b/src/agent/agentic_loop.rs index a8b847dfea8..6cefdb42e62 100644 --- a/src/agent/agentic_loop.rs +++ b/src/agent/agentic_loop.rs @@ -162,7 +162,10 @@ pub async fn run_agentic_loop( "LLM text response" ); } - RespondResult::ToolCalls { tool_calls, content } => { + RespondResult::ToolCalls { + tool_calls, + content, + } => { let names: Vec<&str> = tool_calls.iter().map(|tc| tc.name.as_str()).collect(); tracing::debug!( iteration, diff --git a/src/agent/dispatcher.rs b/src/agent/dispatcher.rs index 64cd820ba99..a91f59a61a7 100644 --- a/src/agent/dispatcher.rs +++ b/src/agent/dispatcher.rs @@ -1092,7 +1092,8 @@ pub(crate) fn extract_suggestions(text: &str) -> (String, Vec) { .and_then(|json| serde_json::from_str::>(&json).ok()) .unwrap_or_default() .into_iter() - .filter(|s| !s.is_empty() && s.len() <= 80) + .filter(|s| !s.trim().is_empty() && s.len() <= 80) + .take(3) .collect(); (cleaned, suggestions) diff --git a/src/agent/thread_ops.rs b/src/agent/thread_ops.rs index 5a9b1052755..3438d1cd7f0 100644 --- a/src/agent/thread_ops.rs +++ b/src/agent/thread_ops.rs @@ -1350,6 +1350,8 @@ impl Agent { match result { Ok(AgenticLoopResult::Response(response)) => { + let (response, suggestions) = + crate::agent::dispatcher::extract_suggestions(&response); thread.complete_turn(&response); let (turn_number, tool_calls) = thread .turns @@ -1380,6 +1382,16 @@ impl Agent { &message.metadata, ) .await; + if !suggestions.is_empty() { + let _ = self + .channels + .send_status( + &message.channel, + StatusUpdate::Suggestions { suggestions }, + &message.metadata, + ) + .await; + } Ok(SubmissionResult::response(response)) } Ok(AgenticLoopResult::NeedApproval { diff --git a/src/channels/wasm/wrapper.rs b/src/channels/wasm/wrapper.rs index 404b6cff445..1529da41b42 100644 --- a/src/channels/wasm/wrapper.rs +++ b/src/channels/wasm/wrapper.rs @@ -1664,7 +1664,9 @@ impl WasmChannel { .await; 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 { tokio::task::spawn_blocking(move || { @@ -1833,7 +1835,9 @@ impl WasmChannel { .await; let pairing_store = self.pairing_store.clone(); 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 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 { let metadata_json = serde_json::to_string(metadata).unwrap_or_default(); - match status { + Some(match status { StatusUpdate::Thinking(msg) => wit_channel::StatusUpdate { status: wit_channel::StatusType::Thinking, message: msg.clone(), @@ -2827,12 +2834,9 @@ fn status_to_wit(status: &StatusUpdate, metadata: &serde_json::Value) -> wit_cha }, metadata_json, }, - StatusUpdate::Suggestions { .. } => wit_channel::StatusUpdate { - status: wit_channel::StatusType::Status, - message: String::new(), - 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). @@ -3561,7 +3565,8 @@ mod tests { let wit = status_to_wit( &crate::channels::StatusUpdate::Thinking("Processing...".into()), &metadata, - ); + ) + .unwrap(); // safety: test assert!(matches!( wit.status, @@ -3579,7 +3584,8 @@ mod tests { let wit = status_to_wit( &crate::channels::StatusUpdate::Status("Done".into()), &metadata, - ); + ) + .unwrap(); // safety: test assert!(matches!(wit.status, super::wit_channel::StatusType::Done)); } @@ -3594,14 +3600,16 @@ mod tests { let wit = status_to_wit( &crate::channels::StatusUpdate::Status("done".into()), &metadata, - ); + ) + .unwrap(); // safety: test assert!(matches!(wit.status, super::wit_channel::StatusType::Done)); // with whitespace let wit = status_to_wit( &crate::channels::StatusUpdate::Status(" Done ".into()), &metadata, - ); + ) + .unwrap(); // safety: test assert!(matches!(wit.status, super::wit_channel::StatusType::Done)); } @@ -3613,7 +3621,8 @@ mod tests { let wit = status_to_wit( &crate::channels::StatusUpdate::Status("Interrupted".into()), &metadata, - ); + ) + .unwrap(); // safety: test assert!(matches!( wit.status, @@ -3631,7 +3640,8 @@ mod tests { let wit = status_to_wit( &crate::channels::StatusUpdate::Status("interrupted".into()), &metadata, - ); + ) + .unwrap(); // safety: test assert!(matches!( wit.status, super::wit_channel::StatusType::Interrupted @@ -3641,7 +3651,8 @@ mod tests { let wit = status_to_wit( &crate::channels::StatusUpdate::Status(" Interrupted ".into()), &metadata, - ); + ) + .unwrap(); // safety: test assert!(matches!( wit.status, super::wit_channel::StatusType::Interrupted @@ -3656,7 +3667,8 @@ mod tests { let wit = status_to_wit( &crate::channels::StatusUpdate::Status("Awaiting approval".into()), &metadata, - ); + ) + .unwrap(); // safety: test assert!(matches!(wit.status, super::wit_channel::StatusType::Status)); assert_eq!(wit.message, "Awaiting approval"); @@ -3675,7 +3687,8 @@ mod tests { setup_url: None, }, &metadata, - ); + ) + .unwrap(); // safety: test assert!(matches!( wit.status, @@ -3695,7 +3708,8 @@ mod tests { name: "http_request".to_string(), }, &metadata, - ); + ) + .unwrap(); // safety: test assert!(matches!( wit.status, @@ -3717,7 +3731,8 @@ mod tests { parameters: None, }, &metadata, - ); + ) + .unwrap(); // safety: test assert!(matches!( wit.status, @@ -3739,7 +3754,8 @@ mod tests { parameters: None, }, &metadata, - ); + ) + .unwrap(); // safety: test assert!(matches!( wit.status, @@ -3759,7 +3775,8 @@ mod tests { preview: "{".to_string() + "\"temperature\": 22}", }, &metadata, - ); + ) + .unwrap(); // safety: test assert!(matches!( wit.status, @@ -3780,7 +3797,8 @@ mod tests { preview: long_preview, }, &metadata, - ); + ) + .unwrap(); // safety: test assert!(matches!( wit.status, @@ -3801,7 +3819,8 @@ mod tests { browse_url: "https://example.com/jobs/job-1".to_string(), }, &metadata, - ); + ) + .unwrap(); // safety: test assert!(matches!( wit.status, @@ -3823,7 +3842,8 @@ mod tests { message: "Token saved".to_string(), }, &metadata, - ); + ) + .unwrap(); // safety: test assert!(matches!( wit.status, @@ -3845,7 +3865,8 @@ mod tests { message: "Invalid token".to_string(), }, &metadata, - ); + ) + .unwrap(); // safety: test assert!(matches!( wit.status, @@ -3868,7 +3889,8 @@ mod tests { parameters: serde_json::json!({"url": "https://api.weather.test"}), }, &metadata, - ); + ) + .unwrap(); // safety: test assert!(matches!( wit.status, @@ -3892,7 +3914,8 @@ mod tests { parameters: serde_json::json!({"url": "https://api.weather.test"}), }, &metadata, - ); + ) + .unwrap(); // safety: test assert!(matches!( wit.status, diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index e1b87f3c280..a981d567799 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -442,6 +442,8 @@ function showSuggestionChips(suggestions) { 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'); @@ -1721,8 +1723,8 @@ chatInput.addEventListener('keydown', (e) => { const acEl = document.getElementById('slash-autocomplete'); const acVisible = acEl && acEl.style.display !== 'none'; - // Accept first suggestion with Tab - if (e.key === 'Tab' && !acVisible && _ghostSuggestion && chatInput.value === '') { + // 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(); diff --git a/src/llm/reasoning.rs b/src/llm/reasoning.rs index 00de63d41c1..f2294f583be 100644 --- a/src/llm/reasoning.rs +++ b/src/llm/reasoning.rs @@ -903,13 +903,7 @@ Example: - Be concise and direct - Use markdown formatting where helpful - For code, use appropriate code blocks with language tags -- ALWAYS end your response with a 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: \"Suggest Italian places nearby\", not \ -\"Tell me your preferences so I can help\". Never phrase suggestions as requests FROM you TO the user. \ -Do NOT repeat or rephrase content already in your response. \ -Example: [\"Suggest dinner spots in my area\", \"Find a quick recipe for pasta\"] \ -Keep each under 80 characters.{} +- ALWAYS end your response with a 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: ["Suggest dinner spots in my area", "Find a quick recipe for pasta"] Keep each under 80 characters.{} ## Safety - You have no independent goals. Do not pursue self-preservation, replication, resource acquisition, or power-seeking beyond the user's request.