feat(opencode): slash command support (#671) (#753)

This commit is contained in:
SSU-WEI HUANG
2026-05-31 19:35:31 +08:00
committed by GitHub
parent 31dd4353d4
commit 5b797bb95d
17 changed files with 775 additions and 28 deletions
+4 -2
View File
@@ -44,10 +44,11 @@ export type CliHandlersDeps = {
onBackgroundTaskDelta?: (sessionId: string, delta: { started: number; completed: number }) => void
onSessionActivity?: (sessionId: string, updatedAt: number) => void
onSweepImmediateQueued?: (sessionId: string, now: number) => void
onMessagesConsumed?: (sessionId: string) => void
}
export function registerCliHandlers(socket: CliSocketWithData, deps: CliHandlersDeps): void {
const { io, store, rpcRegistry, terminalRegistry, onSessionAlive, onSessionEnd, onMachineAlive, onWebappEvent, onBackgroundTaskDelta, onSessionActivity, onSweepImmediateQueued } = deps
const { io, store, rpcRegistry, terminalRegistry, onSessionAlive, onSessionEnd, onMachineAlive, onWebappEvent, onBackgroundTaskDelta, onSessionActivity, onSweepImmediateQueued, onMessagesConsumed } = deps
const terminalNamespace = io.of('/terminal')
const namespace = typeof socket.data.namespace === 'string' ? socket.data.namespace : null
@@ -109,7 +110,8 @@ export function registerCliHandlers(socket: CliSocketWithData, deps: CliHandlers
onWebappEvent,
onBackgroundTaskDelta,
onSessionActivity,
onSweepImmediateQueued
onSweepImmediateQueued,
onMessagesConsumed
})
registerMachineHandlers(socket, {
store,
+13 -2
View File
@@ -67,10 +67,13 @@ export type SessionHandlersDeps = {
onSessionActivity?: (sessionId: string, updatedAt: number) => void
/** Delegates session-end immediate-queue sweep to the MessageService layer. */
onSweepImmediateQueued?: (sessionId: string, now: number) => void
/** Drops the queued-thinking grace so synchronous CLI handlers (e.g. slash
* commands) don't leave the spinner stuck for the full grace window. */
onMessagesConsumed?: (sessionId: string) => void
}
export function registerSessionHandlers(socket: CliSocketWithData, deps: SessionHandlersDeps): void {
const { store, resolveSessionAccess, emitAccessError, onSessionAlive, onSessionEnd, onWebappEvent, onBackgroundTaskDelta, onSessionActivity, onSweepImmediateQueued } = deps
const { store, resolveSessionAccess, emitAccessError, onSessionAlive, onSessionEnd, onWebappEvent, onBackgroundTaskDelta, onSessionActivity, onSweepImmediateQueued, onMessagesConsumed } = deps
socket.on('message', (data: unknown) => {
const parsed = messageSchema.safeParse(data)
@@ -269,7 +272,7 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session
onSessionAlive?.(data)
})
socket.on('messages-consumed', (data: { sid: string; localIds: string[] }) => {
socket.on('messages-consumed', (data: { sid: string; localIds: string[]; clearQueuedThinkingGrace?: boolean }) => {
if (!data || typeof data.sid !== 'string' || !Array.isArray(data.localIds)) {
return
}
@@ -286,6 +289,14 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session
try {
store.messages.markMessagesInvoked(data.sid, localIds, invokedAt)
onSessionActivity?.(data.sid, invokedAt)
// Only drop the queued-thinking grace when the CLI explicitly opts in
// (synchronous handlers like slash commands that will never send
// their own `thinking=true` keepalive). Normal queue drains still
// need the grace so the spinner doesn't flicker between the queue
// shift and `backend.prompt` start.
if (data.clearQueuedThinkingGrace === true) {
onMessagesConsumed?.(data.sid)
}
// Emit only after the DB write succeeds. Otherwise a transient SQLite
// failure would broadcast an `invokedAt` that was never persisted —
// live clients would hide the queued rows while a refresh / secondary
+3 -1
View File
@@ -42,6 +42,7 @@ export type SocketServerDeps = {
onBackgroundTaskDelta?: (sessionId: string, delta: { started: number; completed: number }) => void
onSessionActivity?: (sessionId: string, updatedAt: number) => void
onSweepImmediateQueued?: (sessionId: string, now: number) => void
onMessagesConsumed?: (sessionId: string) => void
}
export function createSocketServer(deps: SocketServerDeps): {
@@ -120,7 +121,8 @@ export function createSocketServer(deps: SocketServerDeps): {
onWebappEvent: deps.onWebappEvent,
onBackgroundTaskDelta: deps.onBackgroundTaskDelta,
onSessionActivity: deps.onSessionActivity,
onSweepImmediateQueued: deps.onSweepImmediateQueued
onSweepImmediateQueued: deps.onSweepImmediateQueued,
onMessagesConsumed: deps.onMessagesConsumed
}))
terminalNs.use(async (socket, next) => {
+2 -1
View File
@@ -189,7 +189,8 @@ export async function startHub(options: StartHubOptions = {}): Promise<HubInstan
onMachineAlive: (payload) => syncEngine?.handleMachineAlive(payload),
onBackgroundTaskDelta: (sessionId, delta) => syncEngine?.handleBackgroundTaskDelta(sessionId, delta),
onSessionActivity: (sessionId, updatedAt) => syncEngine?.recordSessionActivity(sessionId, updatedAt),
onSweepImmediateQueued: (sessionId, now) => syncEngine?.sweepImmediateQueuedOnSessionEnd(sessionId, now)
onSweepImmediateQueued: (sessionId, now) => syncEngine?.sweepImmediateQueuedOnSessionEnd(sessionId, now),
onMessagesConsumed: (sessionId) => syncEngine?.clearQueuedThinkingGrace(sessionId)
})
syncEngine = new SyncEngine(store, socketServer.io, socketServer.rpcRegistry, sseManager)
+16
View File
@@ -260,6 +260,22 @@ export class SessionCache {
}
}
/**
* Drop the queued-message thinking grace timer for a session.
*
* `markMessageQueued` sets a 15s grace during which we keep `thinking=true`
* even if the CLI sends `keepAlive(thinking=false)` — that grace exists to
* cover the gap between the user POSTing a prompt and the CLI starting to
* stream. Sessions that handle the message synchronously (e.g. slash
* commands intercepted in `onUserMessage`) never call onThinkingChange and
* would otherwise leave the spinner stuck for the full grace window. The
* messages-consumed socket event signals the CLI has finished its
* synchronous handling, so it's safe to drop the grace.
*/
clearQueuedThinkingGrace(sessionId: string): void {
this.pendingThinkingUntilBySessionId.delete(sessionId)
}
markMessageQueued(sessionId: string, time: number = Date.now()): void {
const session = this.sessions.get(sessionId) ?? this.refreshSession(sessionId)
if (!session) return
+4
View File
@@ -291,6 +291,10 @@ export class SyncEngine {
this.triggerDedupIfNeeded(payload.sid)
}
clearQueuedThinkingGrace(sessionId: string): void {
this.sessionCache.clearQueuedThinkingGrace(sessionId)
}
handleSessionEnd(payload: { sid: string; time: number; reason?: 'completed' | 'terminated' | 'error' }): void {
this.sessionCache.handleSessionEnd(payload)
this.eventPublisher.emit({