feat(web): slash command autocomplete + /status /list + fix chat input locking (#404)

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

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

---------

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
Co-authored-by: Pierre LE GUEN <[email protected]>
This commit is contained in:
Henry Park
2026-02-27 20:59:32 +00:00
committed by GitHub
co-authored by Claude Sonnet 4.6 Pierre LE GUEN
parent 601d73d16b
commit 9ce09f71b0
7 changed files with 302 additions and 55 deletions
Generated
+13 -1
View File
@@ -955,6 +955,18 @@ dependencies = [
"crossbeam-utils", "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]] [[package]]
name = "const-oid" name = "const-oid"
version = "0.9.6" version = "0.9.6"
@@ -1389,7 +1401,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.116", "syn 2.0.117",
] ]
[[package]] [[package]]
+7
View File
@@ -717,6 +717,13 @@ impl Agent {
Submission::Heartbeat => self.process_heartbeat().await, Submission::Heartbeat => self.process_heartbeat().await,
Submission::Summarize => self.process_summarize(session, thread_id).await, Submission::Summarize => self.process_summarize(session, thread_id).await,
Submission::Suggest => self.process_suggest(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::Quit => return Ok(None),
Submission::SwitchThread { thread_id: target } => { Submission::SwitchThread { thread_id: target } => {
self.process_switch_thread(message, target).await self.process_switch_thread(message, target).await
+27
View File
@@ -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<SubmissionResult, Error> {
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<SubmissionResult, Error> {
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. /// Trigger a manual heartbeat check.
pub(super) async fn process_heartbeat(&self) -> Result<SubmissionResult, Error> { pub(super) async fn process_heartbeat(&self) -> Result<SubmissionResult, Error> {
let Some(workspace) = self.workspace() else { let Some(workspace) = self.workspace() else {
+87
View File
@@ -107,6 +107,29 @@ impl SubmissionParser {
return Submission::Quit; 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 <uuid> - switch thread // /thread <uuid> - switch thread
if let Some(rest) = lower.strip_prefix("/thread ") { if let Some(rest) = lower.strip_prefix("/thread ") {
let rest = rest.trim(); let rest = rest.trim();
@@ -229,6 +252,18 @@ pub enum Submission {
/// Suggest next steps based on the current thread. /// Suggest next steps based on the current thread.
Suggest, 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<String>,
},
/// Cancel a running job.
JobCancel {
/// Job ID (UUID or short prefix).
job_id: String,
},
/// Quit the agent. Bypasses thread-state checks. /// Quit the agent. Bypasses thread-state checks.
Quit, Quit,
@@ -313,6 +348,8 @@ impl Submission {
| Self::Heartbeat | Self::Heartbeat
| Self::Summarize | Self::Summarize
| Self::Suggest | Self::Suggest
| Self::JobStatus { .. }
| Self::JobCancel { .. }
| Self::SystemCommand { .. } | 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] #[test]
fn test_parser_quit() { fn test_parser_quit() {
assert!(matches!(SubmissionParser::parse("/quit"), Submission::Quit)); assert!(matches!(SubmissionParser::parse("/quit"), Submission::Quit));
+125 -27
View File
@@ -16,6 +16,31 @@ let pairingPollInterval = 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;
// --- Slash Commands ---
const SLASH_COMMANDS = [
{ cmd: '/status', desc: 'Show all jobs, or /status <id> for one job' },
{ cmd: '/list', desc: 'List all jobs' },
{ cmd: '/cancel', desc: '/cancel <job-id> — 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 --- // --- Tool Activity State ---
let _activeGroup = null; let _activeGroup = null;
let _activeToolCards = {}; let _activeToolCards = {};
@@ -263,10 +288,8 @@ function isCurrentThread(threadId) {
function sendMessage() { function sendMessage() {
const input = document.getElementById('chat-input'); const input = document.getElementById('chat-input');
const sendBtn = document.getElementById('send-btn');
if (!currentThreadId) { if (!currentThreadId) {
console.warn('sendMessage: no thread selected, ignoring'); console.warn('sendMessage: no thread selected, ignoring');
setStatus('Waiting for thread to load...');
return; return;
} }
const content = input.value.trim(); const content = input.value.trim();
@@ -275,27 +298,73 @@ function sendMessage() {
addMessage('user', content); addMessage('user', content);
input.value = ''; input.value = '';
autoResizeTextarea(input); autoResizeTextarea(input);
sendBtn.disabled = true; input.focus();
input.disabled = true;
apiFetch('/api/chat/send', { apiFetch('/api/chat/send', {
method: 'POST', method: 'POST',
body: { content, thread_id: currentThreadId || undefined }, body: { content, thread_id: currentThreadId || undefined },
}).catch((err) => { }).catch((err) => {
addMessage('system', 'Failed to send: ' + err.message); addMessage('system', 'Failed to send: ' + err.message);
setStatus('');
enableChatInput();
}); });
} }
function enableChatInput() { function enableChatInput() {
// Don't re-enable until a thread is selected (prevents orphan messages) // no-op: input and send button are always enabled
if (!currentThreadId) return; }
// --- 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 = '<span class="slash-ac-cmd">' + escapeHtml(item.cmd) + '</span>'
+ '<span class="slash-ac-desc">' + escapeHtml(item.desc) + '</span>';
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 input = document.getElementById('chat-input');
const sendBtn = document.getElementById('send-btn'); input.value = cmd + ' ';
sendBtn.disabled = false;
input.disabled = false;
input.focus(); 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) { 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 --- // --- Inline Tool Activity Cards ---
function getOrCreateActivityGroup() { function getOrCreateActivityGroup() {
@@ -1097,7 +1157,6 @@ function createNewThread() {
apiFetch('/api/chat/thread/new', { method: 'POST' }).then((data) => { apiFetch('/api/chat/thread/new', { method: 'POST' }).then((data) => {
currentThreadId = data.id || null; currentThreadId = data.id || null;
document.getElementById('chat-messages').innerHTML = ''; document.getElementById('chat-messages').innerHTML = '';
setStatus('');
loadThreads(); loadThreads();
}).catch((err) => { }).catch((err) => {
showToast('Failed to create thread: ' + err.message, 'error'); showToast('Failed to create thread: ' + err.message, 'error');
@@ -1114,16 +1173,50 @@ function toggleThreadSidebar() {
// Chat input auto-resize and keyboard handling // Chat input auto-resize and keyboard handling
const chatInput = document.getElementById('chat-input'); const chatInput = document.getElementById('chat-input');
chatInput.addEventListener('keydown', (e) => { 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) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
hideSlashAutocomplete();
sendMessage(); sendMessage();
} }
}); });
chatInput.addEventListener('input', () => autoResizeTextarea(chatInput)); chatInput.addEventListener('input', () => {
autoResizeTextarea(chatInput);
// Disable send until a thread is selected (loadThreads will enable it) filterSlashCommands(chatInput.value);
chatInput.disabled = true; });
document.getElementById('send-btn').disabled = true; 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 // Infinite scroll: load older messages when scrolled near the top
document.getElementById('chat-messages').addEventListener('scroll', function () { document.getElementById('chat-messages').addEventListener('scroll', function () {
@@ -3612,8 +3705,13 @@ document.addEventListener('keydown', (e) => {
return; return;
} }
// Escape: close job detail or blur input // Escape: close autocomplete, job detail, or blur input
if (e.key === 'Escape') { if (e.key === 'Escape') {
const acEl = document.getElementById('slash-autocomplete');
if (acEl && acEl.style.display !== 'none') {
hideSlashAutocomplete();
return;
}
if (currentJobId) { if (currentJobId) {
closeJobDetail(); closeJobDetail();
} else if (inInput) { } else if (inInput) {
+2 -2
View File
@@ -78,9 +78,9 @@
</div> </div>
<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 class="chat-status" id="chat-status"></div> <div id="slash-autocomplete" class="slash-autocomplete" style="display:none"></div>
<div class="chat-input"> <div class="chat-input">
<textarea id="chat-input" placeholder="Type a message..." rows="1"></textarea> <textarea id="chat-input" placeholder="Message or / for commands..." rows="1"></textarea>
<button id="send-btn" onclick="sendMessage()">Send</button> <button id="send-btn" onclick="sendMessage()">Send</button>
</div> </div>
</div> </div>
+41 -25
View File
@@ -458,31 +458,6 @@ body {
.message th { background: var(--bg-tertiary); } .message th { background: var(--bg-tertiary); }
/* Status bar */ /* 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 { .scroll-load-spinner {
display: flex; display: flex;
@@ -3451,3 +3426,44 @@ mark {
width: 100%; 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;
}