diff --git a/chat/public/builder.html b/chat/public/builder.html
index 53c2bcd..2313648 100644
--- a/chat/public/builder.html
+++ b/chat/public/builder.html
@@ -300,6 +300,26 @@
box-shadow: 0 10px 25px rgba(0, 128, 96, 0.25);
}
+ /* Cancel mode for send button */
+ .primary.cancel-mode {
+ background: linear-gradient(135deg, #ef4444, #dc2626);
+ color: #fff;
+ }
+
+ .primary.cancel-mode:hover {
+ box-shadow: 0 10px 25px rgba(239, 68, 68, 0.25);
+ }
+
+ /* Cancelled message styling */
+ .message.assistant.cancelled {
+ opacity: 0.7;
+ }
+
+ .message.assistant.cancelled .body {
+ background: #fef2f2;
+ border: 1px solid #fecaca;
+ }
+
.back-home {
color: var(--muted);
text-decoration: none;
@@ -1932,11 +1952,16 @@
}
const miniBtn = el.miniSendBtn;
- const originalLabel = miniBtn ? miniBtn.textContent : '';
- if (miniBtn) {
- miniBtn.disabled = true;
- miniBtn.innerHTML = ''; // Clock icon for planning
+
+ // Set sending state for cancellation support
+ if (typeof state !== 'undefined') {
+ state.isSending = true;
+ state.currentSendingMessageId = tempMessageId;
}
+ if (typeof updateSendButtonState === 'function') {
+ updateSendButtonState();
+ }
+
try {
const response = await api('/api/plan', { method: 'POST', body: JSON.stringify(payload) });
@@ -1986,14 +2011,13 @@
renderMessages(session);
}
} finally {
- if (miniBtn) {
- miniBtn.disabled = false;
- // Restore original SVG icon
- miniBtn.innerHTML = ``;
+ // Reset sending state
+ if (typeof state !== 'undefined') {
+ state.isSending = false;
+ state.currentSendingMessageId = null;
+ }
+ if (typeof updateSendButtonState === 'function') {
+ updateSendButtonState();
}
}
}
@@ -2278,7 +2302,15 @@
};
// document.getElementById('send-btn') removed
- document.getElementById('mini-send-btn').addEventListener('click', handleSend, true);
+ // Use handleSendButtonClick which handles both send and cancel modes
+ document.getElementById('mini-send-btn').addEventListener('click', (e) => {
+ if (typeof handleSendButtonClick === 'function') {
+ handleSendButtonClick(e);
+ } else {
+ // Fallback to old behavior if function not loaded yet
+ handleSend(e);
+ }
+ }, true);
// Initialize UI
updateBuildModeUI();
diff --git a/chat/public/builder.js b/chat/public/builder.js
index beae917..a991072 100644
--- a/chat/public/builder.js
+++ b/chat/public/builder.js
@@ -384,6 +384,8 @@ const state = {
usageSummary: null,
todos: [], // Current todos from OpenCode
currentMessageId: null, // Track which message we're displaying todos for
+ isSending: false, // Track if a message is currently being sent
+ currentSendingMessageId: null, // Track the ID of the message being sent for cancellation
};
// Expose state for builder.html
@@ -1824,7 +1826,7 @@ function renderMessages(session) {
// Don't hide loading indicator during streaming - only hide when completely done
// This allows the spinner to continue until the OpenCode session is fully complete
const assistantCard = document.createElement('div');
- assistantCard.className = 'message assistant';
+ assistantCard.className = 'message assistant' + (status === 'cancelled' ? ' cancelled' : '');
const assistantMeta = document.createElement('div');
assistantMeta.className = 'meta';
const msgCliLabel = (msg.cli || session.cli || 'opencode');
@@ -1841,10 +1843,10 @@ function renderMessages(session) {
rawBtn.textContent = 'Plugin Compass';
assistantMeta.appendChild(rawBtn);
- // Add Undo/Redo buttons - only show for latest message and for opencode messages when done
+ // Add Undo/Redo buttons - show for latest message when done, errored, or cancelled
const isOpencodeMsg = msg.cli === 'opencode' || msg.phase === 'build';
const isLatestMessage = latestMessage && latestMessage.id === msg.id;
- const shouldShowUndoRedo = isOpencodeMsg && isLatestMessage && (status === 'done' || status === 'error');
+ const shouldShowUndoRedo = isOpencodeMsg && isLatestMessage && (status === 'done' || status === 'error' || status === 'cancelled');
if (shouldShowUndoRedo) {
const undoBtn = document.createElement('button');
@@ -3721,7 +3723,9 @@ async function sendMessage() {
}
}
- if (el.miniSendBtn) el.miniSendBtn.disabled = true;
+ // Set sending state and update button
+ state.isSending = true;
+ updateSendButtonState();
// Ensure we have a valid current session before proceeding
if (!state.currentSessionId) {
@@ -3801,6 +3805,12 @@ async function sendMessage() {
body: JSON.stringify(payload),
});
+ // Track the message ID being sent for potential cancellation
+ if (response?.message?.id) {
+ state.currentSendingMessageId = response.message.id;
+ console.log('[BUILDER] Tracking message for cancellation:', response.message.id);
+ }
+
// Clear attachments after successful send
pendingAttachments.length = 0;
renderAttachmentPreview();
@@ -3862,12 +3872,143 @@ async function sendMessage() {
renderMessages(currentSession);
}
} finally {
- if (el.miniSendBtn) el.miniSendBtn.disabled = false;
+ // Reset sending state when message completes, is cancelled, or errors out
+ // Only reset if we're not currently in a cancellation (cancelMessage handles that)
+ if (state.isSending && state.currentSendingMessageId) {
+ const session = state.sessions.find(s => s.id === state.currentSessionId);
+ const messageId = state.currentSendingMessageId;
+ const message = session?.messages.find(m => m.id === messageId);
+
+ // Reset if message is done, errored, or cancelled
+ if (!message || message.status === 'done' || message.status === 'error' || message.status === 'cancelled') {
+ state.isSending = false;
+ state.currentSendingMessageId = null;
+ updateSendButtonState();
+ }
+ }
+ }
+}
+
+// Cancel the current message being sent
+async function cancelMessage() {
+ if (!state.isSending || !state.currentSendingMessageId) {
+ console.log('[CANCEL] No message to cancel');
+ return;
+ }
+
+ const messageId = state.currentSendingMessageId;
+ const sessionId = state.currentSessionId;
+
+ console.log('[CANCEL] Cancelling message:', messageId);
+ setStatus('Cancelling...');
+
+ // Close any active SSE streams for this message
+ if (state.activeStreams.has(messageId)) {
+ const stream = state.activeStreams.get(messageId);
+ stream.close();
+ state.activeStreams.delete(messageId);
+ console.log('[CANCEL] Closed SSE stream for message:', messageId);
+ }
+
+ // Find and update the message status
+ const session = state.sessions.find(s => s.id === sessionId);
+ if (session) {
+ const message = session.messages.find(m => m.id === messageId);
+ if (message) {
+ message.status = 'cancelled';
+ message.reply = message.reply || '(Cancelled by user)';
+ message.cancelled = true;
+
+ // Show undo/redo buttons for the cancelled message
+ message.showUndoRedo = true;
+
+ console.log('[CANCEL] Message marked as cancelled:', messageId);
+ }
+
+ // Remove temp messages
+ const tempMessages = session.messages.filter(m => m.id.startsWith('temp-') && m.status === 'queued');
+ tempMessages.forEach(tempMsg => {
+ const idx = session.messages.indexOf(tempMsg);
+ if (idx > -1) {
+ session.messages.splice(idx, 1);
+ }
+ });
+
+ renderMessages(session);
+ }
+
+ // Reset sending state
+ state.isSending = false;
+ state.currentSendingMessageId = null;
+
+ // Reset send button
+ updateSendButtonState();
+
+ hideLoadingIndicator();
+ setStatus('Message cancelled');
+
+ // Refresh session to sync with server
+ try {
+ await refreshCurrentSession();
+ } catch (err) {
+ console.warn('[CANCEL] Failed to refresh session:', err.message);
+ }
+}
+
+// Update send button appearance based on state
+function updateSendButtonState() {
+ const btn = el.miniSendBtn;
+ if (!btn) return;
+
+ if (state.isSending) {
+ // Show cancel button (square icon)
+ btn.innerHTML = `
+
+ `;
+ btn.title = 'Cancel message';
+ btn.classList.add('cancel-mode');
+ btn.disabled = false;
+ } else {
+ // Show send button (paper plane icon)
+ btn.innerHTML = `
+
+ `;
+ btn.title = 'Send message';
+ btn.classList.remove('cancel-mode');
+ btn.disabled = false;
+ }
+}
+
+// Handle send button click - either send or cancel
+function handleSendButtonClick(e) {
+ if (state.isSending) {
+ // Cancel the current message
+ cancelMessage();
+ } else {
+ // Check if we're in plan mode and call the appropriate handler
+ if (builderState.mode === 'plan') {
+ // Use handleSend from builder.html which handles plan mode
+ if (typeof handleSend === 'function') {
+ handleSend(e);
+ } else {
+ console.warn('[BUILDER] handleSend not available');
+ }
+ } else {
+ // Send a new message in build mode
+ sendMessage();
+ }
}
}
// Expose for builder.html
window.sendMessage = sendMessage;
+window.cancelMessage = cancelMessage;
+window.handleSendButtonClick = handleSendButtonClick;
function hookEvents() {
if (el.newChat) {
@@ -4205,7 +4346,11 @@ function hookEvents() {
el.messageInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
- sendMessage();
+ if (state.isSending) {
+ cancelMessage();
+ } else {
+ sendMessage();
+ }
}
});
diff --git a/opencode/packages/opencode/src/provider/provider.ts b/opencode/packages/opencode/src/provider/provider.ts
index 6fae0c0..a4ce132 100644
--- a/opencode/packages/opencode/src/provider/provider.ts
+++ b/opencode/packages/opencode/src/provider/provider.ts
@@ -1200,7 +1200,11 @@ export namespace Provider {
throw new ModelNotFoundError({ providerID, modelID, suggestions })
}
- const info = provider.models[modelID]
+ let info = provider.models[modelID]
+ // Try with provider prefix if not found (e.g., "openrouter/pony-alpha")
+ if (!info && !modelID.includes("/")) {
+ info = provider.models[`${providerID}/${modelID}`]
+ }
if (!info) {
const availableModels = Object.keys(provider.models)
const matches = fuzzysort.go(modelID, availableModels, { limit: 3, threshold: -10000 })
diff --git a/opencode/packages/opencode/test/tool/fixtures/models-api.json b/opencode/packages/opencode/test/tool/fixtures/models-api.json
index 32722e8..1983692 100644
--- a/opencode/packages/opencode/test/tool/fixtures/models-api.json
+++ b/opencode/packages/opencode/test/tool/fixtures/models-api.json
@@ -27310,6 +27310,23 @@
"cost": { "input": 0, "output": 0 },
"limit": { "context": 1840000, "output": 0 }
},
+ "openrouter/pony-alpha": {
+ "id": "openrouter/pony-alpha",
+ "name": "Stealth",
+ "family": "pony",
+ "attachment": false,
+ "reasoning": true,
+ "tool_call": true,
+ "structured_output": true,
+ "temperature": true,
+ "release_date": "2026-02-06",
+ "last_updated": "2026-02-06",
+ "modalities": { "input": ["text"], "output": ["text"] },
+ "open_weights": false,
+ "cost": { "input": 0, "output": 0 },
+ "limit": { "context": 200000, "output": 131000 },
+ "status": "alpha"
+ },
"z-ai/glm-4.7": {
"id": "z-ai/glm-4.7",
"name": "GLM-4.7",