From 9ce09f71b0f8152ed65dd2490934caf1c1736613 Mon Sep 17 00:00:00 2001 From: Henry Park Date: Fri, 27 Feb 2026 12:59:32 -0800 Subject: [PATCH] feat(web): slash command autocomplete + /status /list + fix chat input locking (#404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): slash command autocomplete, /status /list /cancel, fix input locking Backend: - Add JobStatus, JobList, JobCancel Submission variants to submission.rs - Parse /status [id], /progress [id], /list, /cancel as control commands - Dispatch to existing handle_check_status/handle_list_jobs/handle_cancel_job handlers via new process_job_status/process_job_list/process_job_cancel methods - Add 4 parser tests (34 total, all passing) Web UI: - Add slash command autocomplete: type / in chat input to see all 18 commands with descriptions; arrow-key navigation, Tab/Enter to select, Escape to close - Remove chat input locking: drop textarea.disabled + sendBtn.disabled so users can always type and send (including /interrupt while agent is processing) - Remove quick-action toolbar buttons (↩β†ͺβΈβŠ–πŸ—‘πŸ“‹) added in previous session - Remove dead #chat-status bar (min-height 28px black bar always visible when empty) Co-Authored-By: Claude Sonnet 4.6 * refactor: address PR review comments - Remove Submission::JobList variant; parse /list directly as JobStatus { job_id: None } (simpler, eliminates redundant enum variant, match arm, is_control branch, and wrapper function) - Cache autocomplete matches in _slashMatches to avoid re-filtering SLASH_COMMANDS on every keydown while autocomplete is open Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Pierre LE GUEN <26087574+PierreLeGuen@users.noreply.github.com> --- Cargo.lock | 14 ++- src/agent/agent_loop.rs | 7 ++ src/agent/commands.rs | 27 +++++ src/agent/submission.rs | 87 +++++++++++++++++ src/channels/web/static/app.js | 152 ++++++++++++++++++++++++----- src/channels/web/static/index.html | 4 +- src/channels/web/static/style.css | 66 ++++++++----- 7 files changed, 302 insertions(+), 55 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cfccefb5..31fb9c24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -955,6 +955,18 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "windows-sys 0.59.0", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -1389,7 +1401,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index 418b623d..b2e5538b 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -717,6 +717,13 @@ impl Agent { Submission::Heartbeat => self.process_heartbeat().await, Submission::Summarize => self.process_summarize(session, thread_id).await, Submission::Suggest => self.process_suggest(session, thread_id).await, + Submission::JobStatus { job_id } => { + self.process_job_status(&message.user_id, job_id.as_deref()) + .await + } + Submission::JobCancel { job_id } => { + self.process_job_cancel(&message.user_id, &job_id).await + } Submission::Quit => return Ok(None), Submission::SwitchThread { thread_id: target } => { self.process_switch_thread(message, target).await diff --git a/src/agent/commands.rs b/src/agent/commands.rs index 168e9c7b..56778ed4 100644 --- a/src/agent/commands.rs +++ b/src/agent/commands.rs @@ -220,6 +220,33 @@ impl Agent { } } + /// Show job status inline β€” either all jobs (no id) or a specific job. + pub(super) async fn process_job_status( + &self, + user_id: &str, + job_id: Option<&str>, + ) -> Result { + match self + .handle_check_status(user_id, job_id.map(|s| s.to_string())) + .await + { + Ok(text) => Ok(SubmissionResult::response(text)), + Err(e) => Ok(SubmissionResult::error(format!("Job status error: {}", e))), + } + } + + /// Cancel a job by ID. + pub(super) async fn process_job_cancel( + &self, + user_id: &str, + job_id: &str, + ) -> Result { + match self.handle_cancel_job(user_id, job_id).await { + Ok(text) => Ok(SubmissionResult::response(text)), + Err(e) => Ok(SubmissionResult::error(format!("Cancel error: {}", e))), + } + } + /// Trigger a manual heartbeat check. pub(super) async fn process_heartbeat(&self) -> Result { let Some(workspace) = self.workspace() else { diff --git a/src/agent/submission.rs b/src/agent/submission.rs index 87ded36d..cdaba936 100644 --- a/src/agent/submission.rs +++ b/src/agent/submission.rs @@ -107,6 +107,29 @@ impl SubmissionParser { return Submission::Quit; } + // Job commands + if lower == "/status" || lower == "/progress" { + return Submission::JobStatus { job_id: None }; + } + if let Some(rest) = lower + .strip_prefix("/status ") + .or_else(|| lower.strip_prefix("/progress ")) + { + let id = rest.trim().to_string(); + if !id.is_empty() { + return Submission::JobStatus { job_id: Some(id) }; + } + } + if lower == "/list" { + return Submission::JobStatus { job_id: None }; + } + if let Some(rest) = lower.strip_prefix("/cancel ") { + let id = rest.trim().to_string(); + if !id.is_empty() { + return Submission::JobCancel { job_id: id }; + } + } + // /thread - switch thread if let Some(rest) = lower.strip_prefix("/thread ") { let rest = rest.trim(); @@ -229,6 +252,18 @@ pub enum Submission { /// Suggest next steps based on the current thread. Suggest, + /// Check job status. No job_id shows all jobs; with job_id shows a specific job. + JobStatus { + /// Optional job ID (UUID or short prefix). If None, shows all jobs. + job_id: Option, + }, + + /// Cancel a running job. + JobCancel { + /// Job ID (UUID or short prefix). + job_id: String, + }, + /// Quit the agent. Bypasses thread-state checks. Quit, @@ -313,6 +348,8 @@ impl Submission { | Self::Heartbeat | Self::Summarize | Self::Suggest + | Self::JobStatus { .. } + | Self::JobCancel { .. } | Self::SystemCommand { .. } ) } @@ -740,6 +777,56 @@ mod tests { ); } + #[test] + fn test_parser_job_status() { + // /status with no id β†’ all jobs + let s = SubmissionParser::parse("/status"); + assert!(matches!(s, Submission::JobStatus { job_id: None })); + + // /progress alias + let s = SubmissionParser::parse("/progress"); + assert!(matches!(s, Submission::JobStatus { job_id: None })); + + // /status with id + let s = SubmissionParser::parse("/status abc123"); + assert!(matches!(s, Submission::JobStatus { job_id: Some(id) } if id == "abc123")); + + // /progress with id + let s = SubmissionParser::parse("/progress abc123"); + assert!(matches!(s, Submission::JobStatus { job_id: Some(id) } if id == "abc123")); + + // case insensitive + let s = SubmissionParser::parse("/STATUS"); + assert!(matches!(s, Submission::JobStatus { job_id: None })); + } + + #[test] + fn test_parser_job_list() { + // /list is an alias for /status with no job_id + let s = SubmissionParser::parse("/list"); + assert!(matches!(s, Submission::JobStatus { job_id: None })); + + let s = SubmissionParser::parse("/LIST"); + assert!(matches!(s, Submission::JobStatus { job_id: None })); + } + + #[test] + fn test_parser_job_cancel() { + let s = SubmissionParser::parse("/cancel abc123"); + assert!(matches!(s, Submission::JobCancel { job_id } if job_id == "abc123")); + + // /cancel with no id β†’ falls through to UserInput + let s = SubmissionParser::parse("/cancel"); + assert!(matches!(s, Submission::UserInput { .. })); + } + + #[test] + fn test_job_commands_are_control() { + assert!(SubmissionParser::parse("/status").is_control()); + assert!(SubmissionParser::parse("/list").is_control()); + assert!(SubmissionParser::parse("/cancel abc").is_control()); + } + #[test] fn test_parser_quit() { assert!(matches!(SubmissionParser::parse("/quit"), Submission::Quit)); diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index 2566878b..18481253 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -16,6 +16,31 @@ let pairingPollInterval = null; const JOB_EVENTS_CAP = 500; const MEMORY_SEARCH_QUERY_MAX_LENGTH = 100; +// --- Slash Commands --- + +const SLASH_COMMANDS = [ + { cmd: '/status', desc: 'Show all jobs, or /status for one job' }, + { cmd: '/list', desc: 'List all jobs' }, + { cmd: '/cancel', desc: '/cancel β€” cancel a running job' }, + { cmd: '/undo', desc: 'Revert the last turn' }, + { cmd: '/redo', desc: 'Re-apply an undone turn' }, + { cmd: '/compact', desc: 'Compress the context window' }, + { cmd: '/clear', desc: 'Clear thread and start fresh' }, + { cmd: '/interrupt', desc: 'Stop the current turn' }, + { cmd: '/heartbeat', desc: 'Trigger manual heartbeat check' }, + { cmd: '/summarize', desc: 'Summarize the current thread' }, + { cmd: '/suggest', desc: 'Suggest next steps' }, + { cmd: '/help', desc: 'Show help' }, + { cmd: '/version', desc: 'Show version info' }, + { cmd: '/tools', desc: 'List available tools' }, + { cmd: '/skills', desc: 'List installed skills' }, + { cmd: '/model', desc: 'Show or switch the LLM model' }, + { cmd: '/thread new', desc: 'Create a new conversation thread' }, +]; + +let _slashSelected = -1; +let _slashMatches = []; + // --- Tool Activity State --- let _activeGroup = null; let _activeToolCards = {}; @@ -263,10 +288,8 @@ function isCurrentThread(threadId) { function sendMessage() { const input = document.getElementById('chat-input'); - const sendBtn = document.getElementById('send-btn'); if (!currentThreadId) { console.warn('sendMessage: no thread selected, ignoring'); - setStatus('Waiting for thread to load...'); return; } const content = input.value.trim(); @@ -275,27 +298,73 @@ function sendMessage() { addMessage('user', content); input.value = ''; autoResizeTextarea(input); - sendBtn.disabled = true; - input.disabled = true; + input.focus(); apiFetch('/api/chat/send', { method: 'POST', body: { content, thread_id: currentThreadId || undefined }, }).catch((err) => { addMessage('system', 'Failed to send: ' + err.message); - setStatus(''); - enableChatInput(); }); } function enableChatInput() { - // Don't re-enable until a thread is selected (prevents orphan messages) - if (!currentThreadId) return; + // no-op: input and send button are always enabled +} + +// --- Slash Autocomplete --- + +function showSlashAutocomplete(matches) { + const el = document.getElementById('slash-autocomplete'); + if (!el || matches.length === 0) { hideSlashAutocomplete(); return; } + _slashMatches = matches; + _slashSelected = -1; + el.innerHTML = ''; + matches.forEach((item, i) => { + const row = document.createElement('div'); + row.className = 'slash-ac-item'; + row.dataset.index = i; + row.innerHTML = '' + escapeHtml(item.cmd) + '' + + '' + escapeHtml(item.desc) + ''; + row.addEventListener('mousedown', (e) => { + e.preventDefault(); // prevent blur + selectSlashItem(item.cmd); + }); + el.appendChild(row); + }); + el.style.display = 'block'; +} + +function hideSlashAutocomplete() { + const el = document.getElementById('slash-autocomplete'); + if (el) el.style.display = 'none'; + _slashSelected = -1; + _slashMatches = []; +} + +function selectSlashItem(cmd) { const input = document.getElementById('chat-input'); - const sendBtn = document.getElementById('send-btn'); - sendBtn.disabled = false; - input.disabled = false; + input.value = cmd + ' '; input.focus(); + hideSlashAutocomplete(); + autoResizeTextarea(input); +} + +function updateSlashHighlight() { + const items = document.querySelectorAll('#slash-autocomplete .slash-ac-item'); + items.forEach((el, i) => el.classList.toggle('selected', i === _slashSelected)); +} + +function filterSlashCommands(value) { + if (!value.startsWith('/')) { hideSlashAutocomplete(); return; } + // Only show autocomplete when the input is just a slash command prefix (no spaces except /thread new) + const lower = value.toLowerCase(); + const matches = SLASH_COMMANDS.filter((c) => c.cmd.startsWith(lower)); + if (matches.length === 0 || (matches.length === 1 && matches[0].cmd === lower.trimEnd())) { + hideSlashAutocomplete(); + } else { + showSlashAutocomplete(matches); + } } function sendApprovalAction(requestId, action) { @@ -396,15 +465,6 @@ function appendToLastAssistant(chunk) { } } -function setStatus(text) { - const el = document.getElementById('chat-status'); - if (!text) { - el.innerHTML = ''; - return; - } - el.innerHTML = escapeHtml(text); -} - // --- Inline Tool Activity Cards --- function getOrCreateActivityGroup() { @@ -1097,7 +1157,6 @@ function createNewThread() { apiFetch('/api/chat/thread/new', { method: 'POST' }).then((data) => { currentThreadId = data.id || null; document.getElementById('chat-messages').innerHTML = ''; - setStatus(''); loadThreads(); }).catch((err) => { showToast('Failed to create thread: ' + err.message, 'error'); @@ -1114,16 +1173,50 @@ function toggleThreadSidebar() { // Chat input auto-resize and keyboard handling const chatInput = document.getElementById('chat-input'); chatInput.addEventListener('keydown', (e) => { + const acEl = document.getElementById('slash-autocomplete'); + const acVisible = acEl && acEl.style.display !== 'none'; + + if (acVisible) { + const items = acEl.querySelectorAll('.slash-ac-item'); + if (e.key === 'ArrowDown') { + e.preventDefault(); + _slashSelected = Math.min(_slashSelected + 1, items.length - 1); + updateSlashHighlight(); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + _slashSelected = Math.max(_slashSelected - 1, -1); + updateSlashHighlight(); + return; + } + if (e.key === 'Tab' || (e.key === 'Enter' && _slashSelected >= 0)) { + e.preventDefault(); + const pick = _slashSelected >= 0 ? _slashMatches[_slashSelected] : _slashMatches[0]; + if (pick) selectSlashItem(pick.cmd); + return; + } + if (e.key === 'Escape') { + e.preventDefault(); + hideSlashAutocomplete(); + return; + } + } + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); + hideSlashAutocomplete(); sendMessage(); } }); -chatInput.addEventListener('input', () => autoResizeTextarea(chatInput)); - -// Disable send until a thread is selected (loadThreads will enable it) -chatInput.disabled = true; -document.getElementById('send-btn').disabled = true; +chatInput.addEventListener('input', () => { + autoResizeTextarea(chatInput); + filterSlashCommands(chatInput.value); +}); +chatInput.addEventListener('blur', () => { + // Small delay so mousedown on autocomplete item fires first + setTimeout(hideSlashAutocomplete, 150); +}); // Infinite scroll: load older messages when scrolled near the top document.getElementById('chat-messages').addEventListener('scroll', function () { @@ -3612,8 +3705,13 @@ document.addEventListener('keydown', (e) => { return; } - // Escape: close job detail or blur input + // Escape: close autocomplete, job detail, or blur input if (e.key === 'Escape') { + const acEl = document.getElementById('slash-autocomplete'); + if (acEl && acEl.style.display !== 'none') { + hideSlashAutocomplete(); + return; + } if (currentJobId) { closeJobDetail(); } else if (inInput) { diff --git a/src/channels/web/static/index.html b/src/channels/web/static/index.html index 0b87d617..5303ffff 100644 --- a/src/channels/web/static/index.html +++ b/src/channels/web/static/index.html @@ -78,9 +78,9 @@
-
+
- +
diff --git a/src/channels/web/static/style.css b/src/channels/web/static/style.css index 6a8838a3..b887b031 100644 --- a/src/channels/web/static/style.css +++ b/src/channels/web/static/style.css @@ -458,31 +458,6 @@ body { .message th { background: var(--bg-tertiary); } /* Status bar */ -.chat-status { - padding: 6px 16px; - font-size: 12px; - color: var(--text-secondary); - border-top: 1px solid var(--border); - background: var(--bg-secondary); - min-height: 28px; - display: flex; - align-items: center; - gap: 8px; -} - -.chat-status .spinner { - width: 12px; - height: 12px; - border: 2px solid var(--border); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin 0.6s linear infinite; -} - -@keyframes spin { - to { transform: rotate(360deg); } -} - .scroll-load-spinner { display: flex; @@ -3451,3 +3426,44 @@ mark { width: 100%; } } + +/* Slash command autocomplete dropdown */ +.slash-autocomplete { + position: relative; + background: var(--bg-secondary); + border-top: 1px solid var(--border); + border-bottom: none; + max-height: 220px; + overflow-y: auto; + z-index: 50; +} + +.slash-ac-item { + display: flex; + align-items: baseline; + gap: 10px; + padding: 7px 16px; + cursor: pointer; + transition: background 0.1s; +} + +.slash-ac-item:hover, +.slash-ac-item.selected { + background: var(--bg-tertiary); +} + +.slash-ac-cmd { + font-family: var(--font-mono); + font-size: 13px; + color: var(--accent); + white-space: nowrap; + min-width: 130px; +} + +.slash-ac-desc { + font-size: 12px; + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +}