feat: slash commands from agents (#136)

This commit is contained in:
Carlos Florêncio
2026-02-01 01:20:13 +00:00
committed by GitHub
parent 9ddf131a05
commit 5b6be01156
32 changed files with 1171 additions and 373 deletions
+15
View File
@@ -359,6 +359,21 @@ func main() {
}
}
// Send available commands from the execution (for slash command menu after page refresh)
if lifecycleMgr != nil {
commands := lifecycleMgr.GetAvailableCommandsForSession(sessionID)
if len(commands) > 0 {
notification, err := ws.NewNotification(ws.ActionSessionAvailableCommands, map[string]interface{}{
"session_id": sessionID,
"task_id": session.TaskID,
"available_commands": commands,
})
if err == nil {
result = append(result, notification)
}
}
}
return result, nil
})
log.Info("Session data provider configured for session subscriptions (git status from snapshots)")
@@ -65,6 +65,10 @@ type AgentStreamEventData struct {
IsAppend bool `json:"is_append,omitempty"`
// MessageType distinguishes between "message" and "thinking" content types
MessageType string `json:"message_type,omitempty"`
// AvailableCommands contains the slash commands available from the agent.
// Populated when Type is "available_commands".
AvailableCommands []streams.AvailableCommand `json:"available_commands,omitempty"`
}
// AgentStreamEventPayload is the payload for agent stream events (WebSocket streaming).
@@ -281,3 +285,17 @@ type ContextWindowEventPayload struct {
func (p ContextWindowEventPayload) GetSessionID() string {
return p.SessionID
}
// AvailableCommandsEventPayload is the payload for available commands update events.
type AvailableCommandsEventPayload struct {
TaskID string `json:"task_id"`
SessionID string `json:"session_id"`
AgentID string `json:"agent_id"`
AvailableCommands []streams.AvailableCommand `json:"available_commands"`
Timestamp string `json:"timestamp"`
}
// GetSessionID returns the session ID for this event (used by event routing).
func (p AvailableCommandsEventPayload) GetSessionID() string {
return p.SessionID
}
+42 -12
View File
@@ -9,6 +9,7 @@ import (
"go.uber.org/zap"
agentctl "github.com/kandev/kandev/internal/agentctl/client"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
@@ -115,18 +116,19 @@ func (p *EventPublisher) PublishAgentStreamEvent(execution *AgentExecution, even
// Build the nested event data
// event.SessionID is the ACP session ID (internal agent protocol session)
eventData := &AgentStreamEventData{
Type: event.Type,
ACPSessionID: event.SessionID,
Text: event.Text,
ToolCallID: event.ToolCallID,
ParentToolCallID: event.ParentToolCallID,
ToolName: event.ToolName,
ToolTitle: event.ToolTitle,
ToolStatus: event.ToolStatus,
Error: event.Error,
SessionStatus: event.SessionStatus,
Data: event.Data,
Normalized: event.NormalizedPayload,
Type: event.Type,
ACPSessionID: event.SessionID,
Text: event.Text,
ToolCallID: event.ToolCallID,
ParentToolCallID: event.ParentToolCallID,
ToolName: event.ToolName,
ToolTitle: event.ToolTitle,
ToolStatus: event.ToolStatus,
Error: event.Error,
SessionStatus: event.SessionStatus,
Data: event.Data,
Normalized: event.NormalizedPayload,
AvailableCommands: event.AvailableCommands,
}
// Build agent event message payload
@@ -476,3 +478,31 @@ func (p *EventPublisher) PublishContextWindow(execution *AgentExecution, size, u
}
}
// PublishAvailableCommands publishes an available commands update event to the event bus.
func (p *EventPublisher) PublishAvailableCommands(execution *AgentExecution, commands []streams.AvailableCommand) {
if p.eventBus == nil {
return
}
sessionID := execution.SessionID
payload := AvailableCommandsEventPayload{
TaskID: execution.TaskID,
SessionID: sessionID,
AgentID: execution.ID,
AvailableCommands: commands,
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
}
event := bus.NewEvent(events.AvailableCommandsUpdated, "agent-manager", payload)
subject := events.BuildAvailableCommandsSubject(sessionID)
if err := p.eventBus.Publish(context.Background(), subject, event); err != nil {
p.logger.Error("failed to publish available commands event",
zap.String("instance_id", execution.ID),
zap.String("task_id", execution.TaskID),
zap.String("session_id", sessionID),
zap.Error(err))
}
}
@@ -1378,6 +1378,14 @@ func (m *Manager) handleAgentEvent(execution *AgentExecution, event agentctl.Age
bufferPreview = bufferPreview[:100] + "..."
}
// Check if this is an error completion (agent failed to process the prompt)
isError := false
if event.Data != nil {
if v, ok := event.Data["is_error"].(bool); ok {
isError = v
}
}
m.logger.Info("agent turn complete",
zap.String("execution_id", execution.ID),
zap.String("operation_id", event.OperationID),
@@ -1386,7 +1394,8 @@ func (m *Manager) handleAgentEvent(execution *AgentExecution, event agentctl.Age
zap.String("current_msg_id", currentMsgID),
zap.String("event_text", event.Text),
zap.Int("buffer_length", len(bufferContentBeforeFlush)),
zap.String("buffer_preview", bufferPreview))
zap.String("buffer_preview", bufferPreview),
zap.Bool("is_error", isError))
// Get the agent message text:
// 1. If streaming was used, flush the buffer (which publishes final chunk) and ignore event.Text
@@ -1429,11 +1438,33 @@ func (m *Manager) handleAgentEvent(execution *AgentExecution, event agentctl.Age
}
}
// Mark agent as READY for follow-up prompts
if err := m.MarkReady(execution.ID); err != nil {
m.logger.Error("failed to mark execution as ready after complete",
// If this was an error completion, mark the execution as failed
// The agent process will likely exit soon after sending an error result
if isError {
errorMsg := "agent error completion"
if event.Error != "" {
errorMsg = event.Error
}
m.logger.Warn("error completion received, marking execution as failed",
zap.String("execution_id", execution.ID),
zap.Error(err))
zap.String("error", errorMsg))
if err := m.MarkCompleted(execution.ID, 1, errorMsg); err != nil {
m.logger.Error("failed to mark execution as failed after error completion",
zap.String("execution_id", execution.ID),
zap.Error(err))
}
// Remove the execution from the store so a new one can be created
// The AgentFailed event has already been published by MarkCompleted
m.RemoveExecution(execution.ID)
} else {
// Mark agent as READY for follow-up prompts
if err := m.MarkReady(execution.ID); err != nil {
m.logger.Error("failed to mark execution as ready after complete",
zap.String("execution_id", execution.ID),
zap.Error(err))
}
}
case "permission_request":
@@ -1464,6 +1495,21 @@ func (m *Manager) handleAgentEvent(execution *AgentExecution, event agentctl.Age
)
// Return early - context window events don't need to be published as stream events
return
case "available_commands":
// Store available commands in the execution for later retrieval (e.g., after page refresh)
if len(event.AvailableCommands) > 0 {
execution.SetAvailableCommands(event.AvailableCommands)
m.logger.Debug("stored available commands",
zap.String("execution_id", execution.ID),
zap.String("session_id", execution.SessionID),
zap.Int("command_count", len(event.AvailableCommands)))
// Also publish to event bus for WebSocket broadcast
m.eventPublisher.PublishAvailableCommands(execution, event.AvailableCommands)
}
// Return early - we handle broadcasting ourselves
return
}
// Publish agent stream event to event bus for WebSocket streaming
@@ -1894,6 +1940,18 @@ func (m *Manager) GetExecutionBySessionID(sessionID string) (*AgentExecution, bo
return m.executionStore.GetBySessionID(sessionID)
}
// GetAvailableCommandsForSession returns the available slash commands for a session.
// Returns nil if the session doesn't exist or has no commands stored.
//
// Thread-safe: Can be called concurrently from multiple goroutines.
func (m *Manager) GetAvailableCommandsForSession(sessionID string) []streams.AvailableCommand {
execution, exists := m.executionStore.GetBySessionID(sessionID)
if !exists {
return nil
}
return execution.GetAvailableCommands()
}
// ListExecutions returns all currently tracked agent executions.
//
// Returns a snapshot of all executions in memory at the time of call. The returned slice
@@ -10,6 +10,7 @@ import (
"github.com/kandev/kandev/internal/agent/mcpconfig"
agentctl "github.com/kandev/kandev/internal/agentctl/client"
"github.com/kandev/kandev/internal/agentctl/types/streams"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
@@ -74,6 +75,10 @@ type AgentExecution struct {
// via the stream protocol. When true, the session manager should NOT emit a complete
// event after prompt returns, as the adapter already sent one via the stream.
ReportsStatusViaStream bool
// Available commands from the agent (for slash command menu)
availableCommands []streams.AvailableCommand
availableCommandsMu sync.RWMutex
}
// GetAgentCtlClient returns the agentctl client for this execution
@@ -95,6 +100,20 @@ func (ae *AgentExecution) GetWorkspaceStream() *agentctl.WorkspaceStream {
return ae.workspaceStream
}
// SetAvailableCommands sets the available commands for this execution
func (ae *AgentExecution) SetAvailableCommands(commands []streams.AvailableCommand) {
ae.availableCommandsMu.Lock()
defer ae.availableCommandsMu.Unlock()
ae.availableCommands = commands
}
// GetAvailableCommands returns the available commands for this execution
func (ae *AgentExecution) GetAvailableCommands() []streams.AvailableCommand {
ae.availableCommandsMu.RLock()
defer ae.availableCommandsMu.RUnlock()
return ae.availableCommands
}
// LaunchRequest contains parameters for launching an agent
type LaunchRequest struct {
TaskID string
@@ -318,12 +318,13 @@
"cmd": [
"npx",
"-y",
"@anthropic-ai/claude-code@2.1.19",
"@anthropic-ai/claude-code@2.1.29",
"-p",
"--output-format=stream-json",
"--input-format=stream-json",
"--permission-prompt-tool=stdio",
"--disallowedTools=AskUserQuestion",
"--setting-sources=user,project",
"--verbose"
],
"passthrough_config": {
@@ -333,7 +334,7 @@
"passthrough_cmd": [
"npx",
"-y",
"@anthropic-ai/claude-code@2.1.19",
"@anthropic-ai/claude-code",
"--verbose"
],
"model_flag": "--model {model}",
@@ -532,6 +532,20 @@ func (a *Adapter) convertNotification(n acp.SessionNotification) *AgentEvent {
SessionID: string(n.SessionId),
PlanEntries: entries,
}
case u.AvailableCommandsUpdate != nil:
commands := make([]streams.AvailableCommand, len(u.AvailableCommandsUpdate.AvailableCommands))
for i, cmd := range u.AvailableCommandsUpdate.AvailableCommands {
commands[i] = streams.AvailableCommand{
Name: cmd.Name,
Description: cmd.Description,
}
}
return &AgentEvent{
Type: streams.EventTypeAvailableCommands,
SessionID: string(n.SessionId),
AvailableCommands: commands,
}
}
return nil
@@ -7,7 +7,9 @@ import (
"encoding/json"
"fmt"
"io"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared"
@@ -90,6 +92,9 @@ type Adapter struct {
mainModelContextWindow int64 // Context window size (updated from result's model_usage)
contextTokensUsed int64 // Total tokens used (input + output + cache)
// Available commands from initialize response (stored until session is created)
pendingAvailableCommands []streams.AvailableCommand
// Synchronization
mu sync.RWMutex
closed bool
@@ -163,7 +168,16 @@ func (a *Adapter) Initialize(ctx context.Context) error {
a.client.SetMessageHandler(a.handleMessage)
// Start reading from stdout with the adapter's context
a.client.Start(a.ctx)
// Wait for the read loop to be ready before sending initialize
readyC := a.client.Start(a.ctx)
select {
case <-readyC:
a.logger.Info("read loop is ready")
case <-ctx.Done():
return ctx.Err()
case <-time.After(5 * time.Second):
a.logger.Warn("timeout waiting for read loop to start")
}
// Store agent info (version will be populated from first message)
a.agentInfo = &AgentInfo{
@@ -171,6 +185,28 @@ func (a *Adapter) Initialize(ctx context.Context) error {
Version: "unknown",
}
// Send initialize control request to get slash commands
// This is required for streaming mode (input-format=stream-json)
initResp, err := a.client.Initialize(ctx, 30*time.Second)
if err != nil {
a.logger.Warn("failed to initialize (continuing anyway)", zap.Error(err))
} else if initResp != nil && len(initResp.Commands) > 0 {
// Store available commands to emit after session is created
commands := make([]streams.AvailableCommand, len(initResp.Commands))
for i, cmd := range initResp.Commands {
commands[i] = streams.AvailableCommand{
Name: cmd.Name,
Description: cmd.Description,
}
}
a.mu.Lock()
a.pendingAvailableCommands = commands
a.mu.Unlock()
a.logger.Info("received slash commands from initialize",
zap.Int("count", len(commands)))
}
a.logger.Info("stream-json adapter initialized")
return nil
@@ -181,19 +217,42 @@ func (a *Adapter) GetAgentInfo() *AgentInfo {
return a.agentInfo
}
// emitPendingCommands emits any pending available commands for the given session.
// Must be called after the mutex is unlocked.
func (a *Adapter) emitPendingCommands(sessionID string, commands []streams.AvailableCommand) {
if len(commands) == 0 {
return
}
a.sendUpdate(AgentEvent{
Type: streams.EventTypeAvailableCommands,
SessionID: sessionID,
AvailableCommands: commands,
})
a.logger.Debug("emitted pending slash commands",
zap.String("session_id", sessionID),
zap.Int("count", len(commands)))
}
// takePendingCommands atomically takes and clears pending commands.
// Must be called with the mutex held.
func (a *Adapter) takePendingCommands() []streams.AvailableCommand {
commands := a.pendingAvailableCommands
a.pendingAvailableCommands = nil
return commands
}
// NewSession creates a new stream-json session.
// Note: Sessions are created implicitly with the first prompt.
// The mcpServers parameter is ignored as this protocol handles MCP separately.
func (a *Adapter) NewSession(ctx context.Context, _ []types.McpServer) (string, error) {
a.mu.Lock()
defer a.mu.Unlock()
// Generate a session ID - the agent will return its own session ID
// which we'll update when we receive the system message
sessionID := uuid.New().String()
a.sessionID = sessionID
pendingCommands := a.takePendingCommands()
a.mu.Unlock()
a.logger.Info("created new session placeholder", zap.String("session_id", sessionID))
a.emitPendingCommands(sessionID, pendingCommands)
return sessionID, nil
}
@@ -203,9 +262,11 @@ func (a *Adapter) NewSession(ctx context.Context, _ []types.McpServer) (string,
func (a *Adapter) LoadSession(ctx context.Context, sessionID string) error {
a.mu.Lock()
a.sessionID = sessionID
pendingCommands := a.takePendingCommands()
a.mu.Unlock()
a.logger.Info("loaded session", zap.String("session_id", sessionID))
a.emitPendingCommands(sessionID, pendingCommands)
return nil
}
@@ -444,7 +505,7 @@ func (a *Adapter) handleToolPermission(requestID string, req *claudecode.Control
// If no handler, auto-allow
if handler == nil {
a.logger.Info("auto-allowing tool (no handler)",
a.logger.Debug("auto-allowing tool (no handler)",
zap.String("tool", req.ToolName))
a.sendPermissionResponse(requestID, claudecode.BehaviorAllow)
return
@@ -547,7 +608,8 @@ func (a *Adapter) handleMessage(msg *claudecode.CLIMessage) {
func (a *Adapter) handleSystemMessage(msg *claudecode.CLIMessage) {
a.logger.Info("received system message",
zap.String("session_id", msg.SessionID),
zap.String("status", msg.SessionStatus))
zap.String("status", msg.SessionStatus),
zap.Int("slash_commands_count", len(msg.SlashCommands)))
// Update session ID if provided
a.mu.Lock()
@@ -558,6 +620,25 @@ func (a *Adapter) handleSystemMessage(msg *claudecode.CLIMessage) {
a.sessionStatusSent = true
a.mu.Unlock()
// Emit available commands if present (do this on every system message,
// not just the first, in case commands change)
// Note: System message slash_commands is just an array of names (strings),
// so we only have the name, not description. The initialize response has full details.
if len(msg.SlashCommands) > 0 {
commands := make([]streams.AvailableCommand, len(msg.SlashCommands))
for i, name := range msg.SlashCommands {
commands[i] = streams.AvailableCommand{
Name: name,
Description: "", // System message only has names, not descriptions
}
}
a.sendUpdate(AgentEvent{
Type: streams.EventTypeAvailableCommands,
SessionID: msg.SessionID,
AvailableCommands: commands,
})
}
// Only send session status event once per session (on first prompt)
// The agent sends system messages on every prompt, but we only want to
// show "New session started" or "Session resumed" once
@@ -585,13 +666,16 @@ func (a *Adapter) handleAssistantMessage(msg *claudecode.CLIMessage, sessionID,
// Extract parent tool use ID for subagent nesting
parentToolUseID := msg.ParentToolUseID
// Get content blocks (may be nil if content is a string)
contentBlocks := msg.Message.GetContentBlocks()
// Log content block types for debugging
blockTypes := make([]string, 0, len(msg.Message.Content))
for _, block := range msg.Message.Content {
blockTypes := make([]string, 0, len(contentBlocks))
for _, block := range contentBlocks {
blockTypes = append(blockTypes, block.Type)
}
a.logger.Info("processing assistant message",
zap.Int("num_blocks", len(msg.Message.Content)),
a.logger.Debug("processing assistant message",
zap.Int("num_blocks", len(contentBlocks)),
zap.Strings("block_types", blockTypes),
zap.String("parent_tool_use_id", parentToolUseID))
@@ -610,7 +694,7 @@ func (a *Adapter) handleAssistantMessage(msg *claudecode.CLIMessage, sessionID,
}
// Process content blocks
for _, block := range msg.Message.Content {
for _, block := range contentBlocks {
switch block.Type {
case "text":
if block.Text != "" {
@@ -646,7 +730,7 @@ func (a *Adapter) handleAssistantMessage(msg *claudecode.CLIMessage, sessionID,
} else if path, ok := block.Input["file_path"].(string); ok {
toolTitle = fmt.Sprintf("%s: %s", block.Name, path)
}
a.logger.Info("tool_use block received",
a.logger.Debug("tool_use block received",
zap.String("tool_call_id", block.ID),
zap.String("tool_name", block.Name),
zap.String("tool_type", toolType),
@@ -703,15 +787,41 @@ func (a *Adapter) handleAssistantMessage(msg *claudecode.CLIMessage, sessionID,
}
}
// handleUserMessage processes user messages containing tool results.
// handleUserMessage processes user messages containing tool results or slash command output.
// Claude Code sends tool results back as user messages with tool_result content blocks.
// For slash commands, content may be a plain string wrapped in <local-command-stdout> tags.
func (a *Adapter) handleUserMessage(msg *claudecode.CLIMessage, sessionID, operationID string) {
if msg.Message == nil {
return
}
// Check if content is a string (slash command output)
if contentStr := msg.Message.GetContentString(); contentStr != "" {
// Extract text from <local-command-stdout> tags if present
text := contentStr
if strings.HasPrefix(text, "<local-command-stdout>") && strings.HasSuffix(text, "</local-command-stdout>") {
text = strings.TrimPrefix(text, "<local-command-stdout>")
text = strings.TrimSuffix(text, "</local-command-stdout>")
}
if text != "" {
a.logger.Info("received user message with string content (slash command output)",
zap.String("session_id", sessionID),
zap.Int("content_length", len(text)))
a.sendUpdate(AgentEvent{
Type: streams.EventTypeMessageChunk,
SessionID: sessionID,
OperationID: operationID,
Text: text,
})
}
return
}
// Process content blocks looking for tool_result
for _, block := range msg.Message.Content {
contentBlocks := msg.Message.GetContentBlocks()
for _, block := range contentBlocks {
if block.Type != "tool_result" {
continue
}
@@ -769,7 +879,7 @@ func (a *Adapter) handleResultMessage(msg *claudecode.CLIMessage, sessionID, ope
a.mu.Unlock()
for _, toolID := range pendingTools {
a.logger.Info("auto-completing pending tool call on result",
a.logger.Debug("auto-completing pending tool call on result",
zap.String("tool_call_id", toolID))
a.sendUpdate(AgentEvent{
Type: streams.EventTypeToolUpdate,
@@ -829,13 +939,19 @@ func (a *Adapter) handleResultMessage(msg *claudecode.CLIMessage, sessionID, ope
})
// If there's result text, send it as a message chunk
// Result can be either a ResultData object or an error string
// Result can be either a ResultData object or a plain string
var resultText string
if resultData := msg.GetResultData(); resultData != nil && resultData.Text != "" {
resultText = resultData.Text
} else if resultStr := msg.GetResultString(); resultStr != "" {
resultText = resultStr
}
if resultText != "" {
a.sendUpdate(AgentEvent{
Type: streams.EventTypeMessageChunk,
SessionID: sessionID,
OperationID: operationID,
Text: resultData.Text,
Text: resultText,
})
}
@@ -36,6 +36,9 @@ const (
// EventTypeContextWindow indicates a context window update.
EventTypeContextWindow = "context_window"
// EventTypeAvailableCommands indicates available slash commands from the agent.
EventTypeAvailableCommands = "available_commands"
)
// Session status constants for EventTypeSessionStatus events.
@@ -153,6 +156,11 @@ type AgentEvent struct {
// ContextEfficiency is the percentage utilization (0-100).
ContextEfficiency float64 `json:"context_efficiency,omitempty"`
// --- Available commands fields (for "available_commands" type) ---
// AvailableCommands contains the slash commands available from the agent.
AvailableCommands []AvailableCommand `json:"available_commands,omitempty"`
}
// PlanEntry represents an entry in the agent's execution plan.
@@ -166,3 +174,12 @@ type PlanEntry struct {
// Priority indicates relative importance.
Priority string `json:"priority,omitempty"`
}
// AvailableCommand represents a slash command available from the agent.
type AvailableCommand struct {
// Name is the command name (e.g., "draftpr", "commit").
Name string `json:"name"`
// Description is a human-readable description of the command.
Description string `json:"description,omitempty"`
}
+15
View File
@@ -128,6 +128,11 @@ const (
ContextWindowUpdated = "context_window.updated" // Context window usage updated
)
// Event types for available commands
const (
AvailableCommandsUpdated = "available_commands.updated" // Available slash commands updated
)
// BuildShellOutputSubject creates a shell output subject for a specific session
func BuildShellOutputSubject(sessionID string) string {
return ShellOutput + "." + sessionID
@@ -229,3 +234,13 @@ func BuildContextWindowWildcardSubject() string {
return ContextWindowUpdated + ".*"
}
// BuildAvailableCommandsSubject creates an available commands subject for a specific session
func BuildAvailableCommandsSubject(sessionID string) string {
return AvailableCommandsUpdated + "." + sessionID
}
// BuildAvailableCommandsWildcardSubject creates a wildcard subscription for all available commands events
func BuildAvailableCommandsWildcardSubject() string {
return AvailableCommandsUpdated + ".*"
}
@@ -47,6 +47,7 @@ func RegisterSessionStreamNotifications(ctx context.Context, eventBus bus.EventB
b.subscribe(eventBus, events.BuildShellExitWildcardSubject(), ws.ActionSessionShellOutput)
b.subscribe(eventBus, events.BuildProcessOutputWildcardSubject(), ws.ActionSessionProcessOutput)
b.subscribe(eventBus, events.BuildProcessStatusWildcardSubject(), ws.ActionSessionProcessStatus)
b.subscribe(eventBus, events.BuildAvailableCommandsWildcardSubject(), ws.ActionSessionAvailableCommands)
go func() {
<-ctx.Done()
@@ -460,9 +460,15 @@ func (s *Service) handleReviewStepRollback(ctx context.Context, taskID, sessionI
func (s *Service) handleAgentFailed(ctx context.Context, data watcher.AgentEventData) {
s.logger.Warn("handling agent failed",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.String("agent_execution_id", data.AgentExecutionID),
zap.String("error_message", data.ErrorMessage))
// Update session state to FAILED
if data.SessionID != "" {
s.updateTaskSessionState(ctx, data.TaskID, data.SessionID, models.TaskSessionStateFailed, data.ErrorMessage, false)
}
// Trigger retry logic
s.scheduler.HandleTaskCompleted(data.TaskID, false)
if !s.scheduler.RetryTask(data.TaskID) {
@@ -479,6 +485,14 @@ func (s *Service) handleAgentFailed(ctx context.Context, data watcher.AgentEvent
s.logger.Info("task moved to REVIEW state after failure (for user review)",
zap.String("task_id", data.TaskID))
}
} else {
// If retry is triggered, also move task to REVIEW state
// The retry will start a new agent when the task is re-launched
if err := s.taskRepo.UpdateTaskState(ctx, data.TaskID, v1.TaskStateReview); err != nil {
s.logger.Error("failed to update task state to REVIEW after failure (with retry pending)",
zap.String("task_id", data.TaskID),
zap.Error(err))
}
}
}
@@ -617,6 +631,19 @@ func (s *Service) handleAgentStreamEvent(ctx context.Context, payload *lifecycle
}
}
case "available_commands":
// Handle available commands events - broadcast to WebSocket for frontend
if sessionID != "" && s.eventBus != nil && len(payload.Data.AvailableCommands) > 0 {
eventPayload := lifecycle.AvailableCommandsEventPayload{
TaskID: taskID,
SessionID: sessionID,
AgentID: payload.AgentID,
AvailableCommands: payload.Data.AvailableCommands,
}
subject := events.BuildAvailableCommandsSubject(sessionID)
_ = s.eventBus.Publish(ctx, subject, bus.NewEvent(events.AvailableCommandsUpdated, "orchestrator", eventPayload))
}
case "log":
// Handle log events - store agent log messages to the database
if sessionID != "" && s.messageCreator != nil {
+18 -1
View File
@@ -27,6 +27,7 @@ import (
"github.com/kandev/kandev/internal/orchestrator/watcher"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/repository"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// Common errors
@@ -464,17 +465,33 @@ func (s *Service) resumeExecutorsOnStartup(ctx context.Context) {
previousState := session.State
// Handle sessions in terminal states - clean up executor record
// Handle sessions in terminal states - clean up executor record and fix task state
switch previousState {
case models.TaskSessionStateCompleted, models.TaskSessionStateFailed, models.TaskSessionStateCancelled:
s.logger.Info("session in terminal state; cleaning up executor record",
zap.String("session_id", sessionID),
zap.String("task_id", session.TaskID),
zap.String("state", string(previousState)))
if err := s.repo.DeleteExecutorRunningBySessionID(ctx, sessionID); err != nil {
s.logger.Warn("failed to remove executor record for terminal session",
zap.String("session_id", sessionID),
zap.Error(err))
}
// If session failed, ensure task is in REVIEW state (not stuck IN_PROGRESS)
if previousState == models.TaskSessionStateFailed && session.TaskID != "" {
task, taskErr := s.taskRepo.GetTask(ctx, session.TaskID)
if taskErr == nil && task.State == v1.TaskStateInProgress {
s.logger.Info("fixing task state: session failed but task still IN_PROGRESS",
zap.String("task_id", session.TaskID),
zap.String("session_id", sessionID))
if updateErr := s.taskRepo.UpdateTaskState(ctx, session.TaskID, v1.TaskStateReview); updateErr != nil {
s.logger.Warn("failed to update task state to REVIEW",
zap.String("task_id", session.TaskID),
zap.Error(updateErr))
}
}
}
continue
}
+123 -10
View File
@@ -7,7 +7,9 @@ import (
"fmt"
"io"
"sync"
"time"
"github.com/google/uuid"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
)
@@ -19,6 +21,11 @@ type RequestHandler func(requestID string, req *ControlRequest)
// MessageHandler handles streaming messages from Claude Code CLI.
type MessageHandler func(msg *CLIMessage)
// pendingRequest tracks a control request waiting for a response.
type pendingRequest struct {
ch chan *IncomingControlResponse
}
// Client handles Claude Code CLI communication over stdin/stdout streams.
// It reads streaming JSON from stdout and writes control messages to stdin.
type Client struct {
@@ -30,6 +37,10 @@ type Client struct {
requestHandler RequestHandler
messageHandler MessageHandler
// Pending control requests (requests we sent, waiting for responses)
pendingRequests map[string]*pendingRequest
pendingRequestsMu sync.Mutex
// Synchronization
mu sync.RWMutex
done chan struct{}
@@ -38,10 +49,11 @@ type Client struct {
// NewClient creates a new Claude Code CLI client.
func NewClient(stdin io.Writer, stdout io.Reader, log *logger.Logger) *Client {
return &Client{
stdin: stdin,
stdout: stdout,
logger: log.WithFields(zap.String("component", "claudecode-client")),
done: make(chan struct{}),
stdin: stdin,
stdout: stdout,
logger: log.WithFields(zap.String("component", "claudecode-client")),
done: make(chan struct{}),
pendingRequests: make(map[string]*pendingRequest),
}
}
@@ -60,8 +72,11 @@ func (c *Client) SetMessageHandler(handler MessageHandler) {
}
// Start begins reading from stdout in a goroutine.
func (c *Client) Start(ctx context.Context) {
go c.readLoop(ctx)
// Returns a channel that is closed when the read loop is ready.
func (c *Client) Start(ctx context.Context) <-chan struct{} {
ready := make(chan struct{})
go c.readLoop(ctx, ready)
return ready
}
// Stop stops the client and closes the done channel.
@@ -77,6 +92,62 @@ func (c *Client) Stop() {
}
}
// Initialize sends the initialize control request to Claude Code CLI and waits for the response.
// This must be called in streaming mode (input-format=stream-json) to get slash commands.
func (c *Client) Initialize(ctx context.Context, timeout time.Duration) (*InitializeResponseData, error) {
requestID := uuid.New().String()
// Create pending request channel
pending := &pendingRequest{
ch: make(chan *IncomingControlResponse, 1),
}
c.pendingRequestsMu.Lock()
c.pendingRequests[requestID] = pending
c.pendingRequestsMu.Unlock()
defer func() {
c.pendingRequestsMu.Lock()
delete(c.pendingRequests, requestID)
c.pendingRequestsMu.Unlock()
}()
// Send initialize control request
req := &SDKControlRequest{
Type: MessageTypeControlRequest,
RequestID: requestID,
Request: SDKControlRequestBody{
Subtype: SubtypeInitialize,
Hooks: nil, // We don't use SDK hooks
},
}
c.logger.Info("sending initialize control request", zap.String("request_id", requestID))
if err := c.send(req); err != nil {
return nil, fmt.Errorf("failed to send initialize request: %w", err)
}
// Wait for response with timeout
c.logger.Info("waiting for initialize response", zap.Duration("timeout", timeout))
select {
case <-ctx.Done():
c.logger.Warn("initialize cancelled by context")
return nil, ctx.Err()
case <-time.After(timeout):
c.logger.Warn("initialize request timed out", zap.Duration("timeout", timeout))
return nil, fmt.Errorf("initialize request timed out after %v", timeout)
case resp := <-pending.ch:
if resp.Subtype == "error" {
return nil, fmt.Errorf("initialize failed: %s", resp.Error)
}
c.logger.Info("initialize response received",
zap.Int("commands", len(resp.Response.Commands)),
zap.Int("agents", len(resp.Response.Agents)))
return resp.Response, nil
}
}
// SendControlRequest sends a control request to Claude Code CLI.
func (c *Client) SendControlRequest(req *SDKControlRequest) error {
return c.send(req)
@@ -113,12 +184,16 @@ func (c *Client) send(msg any) error {
return nil
}
func (c *Client) readLoop(ctx context.Context) {
func (c *Client) readLoop(ctx context.Context, ready chan<- struct{}) {
scanner := bufio.NewScanner(c.stdout)
// Allow for large JSON messages (up to 10MB)
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, 10*1024*1024)
// Signal that we're ready to read
c.logger.Info("claudecode: read loop starting")
close(ready)
for scanner.Scan() {
select {
case <-ctx.Done():
@@ -142,6 +217,10 @@ func (c *Client) readLoop(ctx context.Context) {
}
func (c *Client) handleLine(line []byte) {
// Log all incoming lines for debugging
c.logger.Debug("claudecode: received raw line",
zap.String("line", string(line)))
// First, parse the basic structure to determine message type
var msg CLIMessage
if err := json.Unmarshal(line, &msg); err != nil {
@@ -149,16 +228,24 @@ func (c *Client) handleLine(line []byte) {
return
}
c.logger.Debug("claudecode: received message",
c.logger.Debug("claudecode: parsed message",
zap.String("type", msg.Type),
zap.String("request_id", msg.RequestID))
zap.String("request_id", msg.RequestID),
zap.Bool("has_response", msg.Response != nil))
// Handle control requests specially - they need immediate response
// Handle control requests (from Claude to us, e.g., permission requests)
if msg.Type == MessageTypeControlRequest && msg.Request != nil {
c.handleControlRequest(msg.RequestID, msg.Request)
return
}
// Handle control responses (from Claude back to us, e.g., initialize response)
// Note: request_id is inside the response object, not at the message level
if msg.Type == MessageTypeControlResponse && msg.Response != nil {
c.handleControlResponse(msg.Response)
return
}
// Forward other messages to the message handler
c.mu.RLock()
handler := c.messageHandler
@@ -195,3 +282,29 @@ func (c *Client) handleControlRequest(requestID string, req *ControlRequest) {
}
}
}
func (c *Client) handleControlResponse(resp *IncomingControlResponse) {
requestID := resp.RequestID
c.pendingRequestsMu.Lock()
pending, ok := c.pendingRequests[requestID]
c.pendingRequestsMu.Unlock()
if !ok {
c.logger.Warn("received control response for unknown request",
zap.String("request_id", requestID),
zap.String("subtype", resp.Subtype))
return
}
c.logger.Info("received control response",
zap.String("request_id", requestID),
zap.String("subtype", resp.Subtype))
// Send response to waiting goroutine
select {
case pending.ch <- resp:
default:
c.logger.Warn("pending request channel full", zap.String("request_id", requestID))
}
}
@@ -278,3 +278,63 @@ func TestClient_InvalidJSON(t *testing.T) {
t.Errorf("count = %d, want 1", count)
}
}
func TestClient_HandleControlResponse(t *testing.T) {
// Simulate a control response coming back from Claude Code CLI
responseJSON := `{"type":"control_response","response":{"subtype":"success","request_id":"test-req-123","response":{"commands":[{"name":"cost","description":"Show cost"}],"agents":["Bash"]}}}` + "\n"
var buf bytes.Buffer
client := NewClient(&buf, strings.NewReader(responseJSON), newTestLogger())
// Register a pending request
pending := &pendingRequest{
ch: make(chan *IncomingControlResponse, 1),
}
client.pendingRequestsMu.Lock()
client.pendingRequests["test-req-123"] = pending
client.pendingRequestsMu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
client.Start(ctx)
// Wait for the response
select {
case resp := <-pending.ch:
if resp.Subtype != "success" {
t.Errorf("Subtype = %q, want %q", resp.Subtype, "success")
}
if resp.RequestID != "test-req-123" {
t.Errorf("RequestID = %q, want %q", resp.RequestID, "test-req-123")
}
if resp.Response == nil {
t.Fatal("Response is nil")
}
if len(resp.Response.Commands) != 1 {
t.Errorf("Commands count = %d, want %d", len(resp.Response.Commands), 1)
}
if resp.Response.Commands[0].Name != "cost" {
t.Errorf("Commands[0].Name = %q, want %q", resp.Response.Commands[0].Name, "cost")
}
case <-time.After(50 * time.Millisecond):
t.Error("timeout waiting for control response")
}
}
func TestClient_HandleControlResponse_UnknownRequest(t *testing.T) {
// Simulate a control response for an unknown request ID (should be ignored)
responseJSON := `{"type":"control_response","response":{"subtype":"success","request_id":"unknown-req","response":{"commands":[]}}}` + "\n"
var buf bytes.Buffer
client := NewClient(&buf, strings.NewReader(responseJSON), newTestLogger())
// Don't register any pending request - response should be ignored
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
client.Start(ctx)
time.Sleep(50 * time.Millisecond)
// Test passes if no panic occurs - the unknown response is just logged and ignored
}
+73 -9
View File
@@ -45,16 +45,20 @@ const (
// CLIMessage represents messages from Claude Code CLI stdout.
// The message type determines which fields are populated.
type CLIMessage struct {
// Type is the message type (system, assistant, result, control_request, etc.)
// Type is the message type (system, assistant, result, control_request, control_response, etc.)
Type string `json:"type"`
// For control_request messages
// For control_request messages (from Claude to us)
RequestID string `json:"request_id,omitempty"`
Request *ControlRequest `json:"request,omitempty"`
// For control_response messages (from Claude back to us after we send a control_request)
Response *IncomingControlResponse `json:"response,omitempty"`
// For system messages
SessionID string `json:"session_id,omitempty"`
SessionStatus string `json:"session_status,omitempty"`
SessionID string `json:"session_id,omitempty"`
SessionStatus string `json:"session_status,omitempty"`
SlashCommands []string `json:"slash_commands,omitempty"` // System message uses string array
// For subagent context (when running inside a Task tool call)
ParentToolUseID string `json:"parent_tool_use_id,omitempty"`
@@ -80,12 +84,40 @@ type CLIMessage struct {
}
// AssistantMessage contains the assistant's response content.
// Note: For user messages (type="user"), Content may be a plain string instead of []ContentBlock.
// Use RawContent and GetContentBlocks()/GetContentString() for flexible parsing.
type AssistantMessage struct {
Role string `json:"role"`
Content []ContentBlock `json:"content,omitempty"`
Model string `json:"model,omitempty"`
StopReason string `json:"stop_reason,omitempty"`
Usage *Usage `json:"usage,omitempty"`
Role string `json:"role"`
Content json.RawMessage `json:"content,omitempty"` // Can be string or []ContentBlock
Model string `json:"model,omitempty"`
StopReason string `json:"stop_reason,omitempty"`
Usage *Usage `json:"usage,omitempty"`
}
// GetContentBlocks attempts to parse Content as []ContentBlock.
// Returns nil if Content is a string or cannot be parsed.
func (m *AssistantMessage) GetContentBlocks() []ContentBlock {
if len(m.Content) == 0 {
return nil
}
var blocks []ContentBlock
if err := json.Unmarshal(m.Content, &blocks); err != nil {
return nil
}
return blocks
}
// GetContentString attempts to parse Content as a plain string.
// Returns empty string if Content is []ContentBlock or cannot be parsed.
func (m *AssistantMessage) GetContentString() string {
if len(m.Content) == 0 {
return ""
}
var s string
if err := json.Unmarshal(m.Content, &s); err != nil {
return ""
}
return s
}
// ContentBlock represents a block of content in an assistant message.
@@ -190,6 +222,38 @@ type ControlResponseMessage struct {
Response *ControlResponse `json:"response"`
}
// IncomingControlResponse represents a control_response message from Claude Code CLI
// (response to a control_request we sent, like initialize).
type IncomingControlResponse struct {
// Subtype is the response type (success, error)
Subtype string `json:"subtype"`
// RequestID is the ID of the control_request this responds to
RequestID string `json:"request_id"`
// Response contains the response data (e.g., slash_commands for initialize)
Response *InitializeResponseData `json:"response,omitempty"`
// Error message if subtype is "error"
Error string `json:"error,omitempty"`
}
// InitializeResponseData contains the response data from an initialize control request.
type InitializeResponseData struct {
// Commands are the available commands (Claude Code uses "commands" not "slash_commands")
Commands []Command `json:"commands,omitempty"`
// Agents are the available agent names
Agents []string `json:"agents,omitempty"`
}
// Command represents an available command from the initialize response.
type Command struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
ArgumentHint string `json:"argumentHint,omitempty"`
}
// ControlResponse is the response to a control request.
type ControlResponse struct {
// Subtype is the response type (success, error)
+151
View File
@@ -289,3 +289,154 @@ func TestModelUsageStats_ContextWindow(t *testing.T) {
t.Errorf("ContextWindow = %v, want nil", stats2.ContextWindow)
}
}
func TestAssistantMessage_GetContentBlocks(t *testing.T) {
tests := []struct {
name string
content string
wantCount int
wantType string
}{
{
name: "array of content blocks",
content: `[{"type":"text","text":"Hello"},{"type":"text","text":"World"}]`,
wantCount: 2,
wantType: "text",
},
{
name: "single content block",
content: `[{"type":"thinking","thinking":"Let me think..."}]`,
wantCount: 1,
wantType: "thinking",
},
{
name: "empty array",
content: `[]`,
wantCount: 0,
wantType: "",
},
{
name: "string content (not blocks)",
content: `"This is a string"`,
wantCount: 0,
wantType: "",
},
{
name: "empty content",
content: ``,
wantCount: 0,
wantType: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
msg := &AssistantMessage{
Content: json.RawMessage(tt.content),
}
blocks := msg.GetContentBlocks()
if len(blocks) != tt.wantCount {
t.Errorf("GetContentBlocks() returned %d blocks, want %d", len(blocks), tt.wantCount)
}
if tt.wantCount > 0 && blocks[0].Type != tt.wantType {
t.Errorf("GetContentBlocks()[0].Type = %q, want %q", blocks[0].Type, tt.wantType)
}
})
}
}
func TestAssistantMessage_GetContentString(t *testing.T) {
tests := []struct {
name string
content string
want string
}{
{
name: "plain string content",
content: `"Hello, World!"`,
want: "Hello, World!",
},
{
name: "string with local-command-stdout tags",
content: `"<local-command-stdout>Command output here</local-command-stdout>"`,
want: "<local-command-stdout>Command output here</local-command-stdout>",
},
{
name: "empty string",
content: `""`,
want: "",
},
{
name: "array content (not string)",
content: `[{"type":"text","text":"Hello"}]`,
want: "",
},
{
name: "empty content",
content: ``,
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
msg := &AssistantMessage{
Content: json.RawMessage(tt.content),
}
got := msg.GetContentString()
if got != tt.want {
t.Errorf("GetContentString() = %q, want %q", got, tt.want)
}
})
}
}
func TestIncomingControlResponse_JSONParsing(t *testing.T) {
// Test successful initialize response
jsonStr := `{
"subtype": "success",
"request_id": "req-123",
"response": {
"commands": [
{"name": "cost", "description": "Show cost"},
{"name": "context", "description": "Show context"}
],
"agents": ["Bash", "Explore"]
}
}`
var resp IncomingControlResponse
if err := json.Unmarshal([]byte(jsonStr), &resp); err != nil {
t.Fatalf("failed to parse: %v", err)
}
if resp.Subtype != "success" {
t.Errorf("Subtype = %q, want %q", resp.Subtype, "success")
}
if resp.RequestID != "req-123" {
t.Errorf("RequestID = %q, want %q", resp.RequestID, "req-123")
}
if resp.Response == nil {
t.Fatal("Response is nil")
}
if len(resp.Response.Commands) != 2 {
t.Errorf("Commands count = %d, want %d", len(resp.Response.Commands), 2)
}
if resp.Response.Commands[0].Name != "cost" {
t.Errorf("Commands[0].Name = %q, want %q", resp.Response.Commands[0].Name, "cost")
}
if len(resp.Response.Agents) != 2 {
t.Errorf("Agents count = %d, want %d", len(resp.Response.Agents), 2)
}
// Test error response
errorJSON := `{"subtype": "error", "request_id": "req-456", "error": "Something went wrong"}`
var errorResp IncomingControlResponse
if err := json.Unmarshal([]byte(errorJSON), &errorResp); err != nil {
t.Fatalf("failed to parse error response: %v", err)
}
if errorResp.Subtype != "error" {
t.Errorf("Subtype = %q, want %q", errorResp.Subtype, "error")
}
if errorResp.Error != "Something went wrong" {
t.Errorf("Error = %q, want %q", errorResp.Error, "Something went wrong")
}
}
+3 -2
View File
@@ -141,8 +141,9 @@ const (
ActionSessionAgentctlStarting = "session.agentctl_starting"
ActionSessionAgentctlReady = "session.agentctl_ready"
ActionSessionAgentctlError = "session.agentctl_error"
ActionSessionTurnStarted = "session.turn.started"
ActionSessionTurnCompleted = "session.turn.completed"
ActionSessionTurnStarted = "session.turn.started"
ActionSessionTurnCompleted = "session.turn.completed"
ActionSessionAvailableCommands = "session.available_commands"
ActionInputRequested = "input.requested"
ActionRepositoryCreated = "repository.created"
ActionRepositoryUpdated = "repository.updated"
@@ -208,8 +208,19 @@ export function ChatInputContainer({
// Mention menu hook
const mention = useInlineMention(inputRef, value, setValue, sessionId);
// Handler for agent slash commands - sends /{commandName} as a message
const handleAgentCommand = useCallback(
(commandName: string) => {
onSubmit(`/${commandName}`);
},
[onSubmit]
);
// Slash command menu hook
const slash = useInlineSlash(inputRef, value, setValue, onPlanModeChange);
const slash = useInlineSlash(inputRef, value, setValue, {
sessionId,
onAgentCommand: handleAgentCommand,
});
// Use refs for menu state to keep handleSubmit stable
const menuStateRef = useRef({ mentionOpen: mention.isOpen, slashOpen: slash.isOpen });
+50 -150
View File
@@ -1,10 +1,8 @@
'use client';
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { IconAt, IconFile, IconFolder } from '@tabler/icons-react';
import { cn } from '@/lib/utils';
import type { MentionItem } from '@/hooks/use-inline-mention';
import { PopupMenu, PopupMenuItem, useMenuItemRefs } from './popup-menu';
type MentionMenuProps = {
isOpen: boolean;
@@ -18,9 +16,6 @@ type MentionMenuProps = {
setSelectedIndex: (index: number) => void;
};
const MENU_HEIGHT = 280;
const MENU_WIDTH = 320;
// Extract filename and parent path from a full path
function parseFilePath(filePath: string): { name: string; parent: string } {
const parts = filePath.split('/');
@@ -36,6 +31,24 @@ function isDirectory(filePath: string): boolean {
return !name.includes('.');
}
// Get the appropriate icon for an item
function getItemIcon(item: MentionItem) {
if (item.type === 'prompt') {
return <IconAt className="h-4 w-4" />;
}
const isDir = isDirectory(item.label);
return isDir ? <IconFolder className="h-4 w-4" /> : <IconFile className="h-4 w-4" />;
}
// Get the label and description for an item
function getItemDisplay(item: MentionItem): { label: string; description?: string } {
if (item.type === 'prompt') {
return { label: item.label, description: item.description };
}
const { name, parent } = parseFilePath(item.label);
return { label: name, description: parent || undefined };
}
export function MentionMenu({
isOpen,
isLoading,
@@ -47,152 +60,39 @@ export function MentionMenu({
onClose,
setSelectedIndex,
}: MentionMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
const { setItemRef } = useMenuItemRefs(selectedIndex);
// Scroll selected item into view
useEffect(() => {
const selectedItem = itemRefs.current.get(selectedIndex);
if (selectedItem) {
selectedItem.scrollIntoView({ block: 'nearest' });
}
}, [selectedIndex]);
// Close on click outside
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen, onClose]);
if (!isOpen || !position) {
return null;
}
// Calculate position (above cursor)
const menuStyle: React.CSSProperties = {
position: 'fixed',
left: Math.max(8, Math.min(position.x, window.innerWidth - MENU_WIDTH - 8)),
bottom: window.innerHeight - position.y + 8,
width: MENU_WIDTH,
maxHeight: MENU_HEIGHT,
zIndex: 50,
};
// Group items by type
const prompts = items.filter((item) => item.type === 'prompt');
const files = items.filter((item) => item.type === 'file');
// Calculate indices for sections
let currentIndex = 0;
const menu = (
<div
ref={menuRef}
style={menuStyle}
className="overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 text-xs"
>
{/* Header */}
<div className="px-2 py-1.5 border-b border-border/50">
<span className="text-xs font-medium text-muted-foreground">Mention files, prompts</span>
</div>
{/* Content */}
<div className="overflow-y-auto py-1 scrollbar-thin" style={{ maxHeight: MENU_HEIGHT - 36 }}>
{items.length === 0 ? (
<div className="px-3 py-1 text-center text-xs text-muted-foreground">
{isLoading ? 'Loading...' : query ? 'No results found' : 'Type to search...'}
</div>
) : (
<>
{prompts.length > 0 && (
<div>
{prompts.map((item) => {
const index = currentIndex++;
return (
<button
key={item.id}
ref={(el) => {
if (el) itemRefs.current.set(index, el);
}}
type="button"
className={cn(
'flex w-full cursor-pointer select-none items-center gap-3 rounded-[6px] mx-1 px-2 py-1.5 text-[13px] text-left',
'hover:bg-muted/50',
selectedIndex === index && 'bg-muted/50'
)}
style={{ width: 'calc(100% - 8px)' }}
onClick={() => onSelect(item)}
onMouseEnter={() => setSelectedIndex(index)}
>
<IconAt className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex items-baseline gap-2 min-w-0 flex-1">
<span className="shrink-0">{item.label}</span>
{item.description && (
<span className="text-[11px] text-muted-foreground truncate">
{item.description}
</span>
)}
</div>
</button>
);
})}
</div>
)}
{files.length > 0 && (
<div>
{files.map((item) => {
const index = currentIndex++;
const { name, parent } = parseFilePath(item.label);
const isDir = isDirectory(item.label);
return (
<button
key={item.id}
ref={(el) => {
if (el) itemRefs.current.set(index, el);
}}
type="button"
className={cn(
'flex w-full cursor-pointer select-none items-center gap-3 rounded-[6px] mx-1 px-2 py-1.5 text-xs text-left',
'hover:bg-muted/50',
selectedIndex === index && 'bg-muted/50'
)}
style={{ width: 'calc(100% - 8px)' }}
onClick={() => onSelect(item)}
onMouseEnter={() => setSelectedIndex(index)}
>
{isDir ? (
<IconFolder className="h-4 w-4 text-muted-foreground shrink-0" />
) : (
<IconFile className="h-4 w-4 text-muted-foreground shrink-0" />
)}
<div className="flex items-baseline gap-2 min-w-0 flex-1">
<span className="shrink-0">{name}</span>
{parent && (
<span className="text-[11px] text-muted-foreground truncate">{parent}</span>
)}
</div>
</button>
);
})}
</div>
)}
</>
)}
</div>
const emptyState = (
<div className="px-3 py-1 text-center text-xs text-muted-foreground">
{isLoading ? 'Loading...' : query ? 'No results found' : 'Type to search...'}
</div>
);
// Render via portal to escape any overflow containers
if (typeof document === 'undefined') return null;
return createPortal(menu, document.body);
return (
<PopupMenu
isOpen={isOpen}
position={position}
title="Mention files, prompts"
selectedIndex={selectedIndex}
onClose={onClose}
hasItems={items.length > 0}
emptyState={emptyState}
>
{items.map((item, index) => {
const { label, description } = getItemDisplay(item);
return (
<PopupMenuItem
key={item.id}
icon={getItemIcon(item)}
label={label}
description={description}
isSelected={selectedIndex === index}
onClick={() => onSelect(item)}
onMouseEnter={() => setSelectedIndex(index)}
itemRef={setItemRef(index)}
/>
);
})}
</PopupMenu>
);
}
@@ -0,0 +1,148 @@
'use client';
import { useEffect, useRef, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { cn } from '@/lib/utils';
const MENU_HEIGHT = 280;
const MENU_MAX_WIDTH = 700;
export type PopupMenuProps = {
isOpen: boolean;
position: { x: number; y: number } | null;
title: string;
selectedIndex: number;
onClose: () => void;
children: ReactNode;
emptyState?: ReactNode;
hasItems?: boolean;
};
export function PopupMenu({
isOpen,
position,
title,
onClose,
children,
emptyState,
hasItems = true,
}: PopupMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
// Close on click outside
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen, onClose]);
if (!isOpen || !position) {
return null;
}
// Calculate position (above cursor) with content-based width
const menuStyle: React.CSSProperties = {
position: 'fixed',
left: Math.max(8, position.x),
bottom: window.innerHeight - position.y + 8,
maxWidth: Math.min(MENU_MAX_WIDTH, window.innerWidth - position.x - 8),
maxHeight: MENU_HEIGHT,
zIndex: 50,
};
const menu = (
<div
ref={menuRef}
style={menuStyle}
className="overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10"
>
{/* Header */}
<div className="px-2 py-1.5 border-b border-border/50">
<span className="text-xs font-medium text-muted-foreground">{title}</span>
</div>
{/* Content */}
<div className="overflow-y-auto py-1 scrollbar-thin" style={{ maxHeight: MENU_HEIGHT - 36 }}>
{hasItems ? children : emptyState}
</div>
</div>
);
// Render via portal to escape any overflow containers
if (typeof document === 'undefined') return null;
return createPortal(menu, document.body);
}
export type PopupMenuItemProps = {
icon: ReactNode;
label: string;
description?: string;
isSelected: boolean;
onClick: () => void;
onMouseEnter: () => void;
itemRef?: (el: HTMLButtonElement | null) => void;
};
export function PopupMenuItem({
icon,
label,
description,
isSelected,
onClick,
onMouseEnter,
itemRef,
}: PopupMenuItemProps) {
return (
<button
ref={itemRef}
type="button"
className={cn(
'flex w-full cursor-pointer select-none items-center gap-3 rounded-[6px] mx-1 px-2 py-1.5 text-xs text-left',
'hover:bg-muted/50',
isSelected && 'bg-muted/50'
)}
style={{ width: 'calc(100% - 8px)' }}
onClick={onClick}
onMouseEnter={onMouseEnter}
>
<div className="h-4 w-4 text-muted-foreground shrink-0 flex items-center justify-center">
{icon}
</div>
<div className="flex items-baseline gap-2 min-w-0 flex-1">
<span className="shrink-0 font-medium">{label}</span>
{description && (
<span className="text-[11px] text-muted-foreground whitespace-nowrap">
{description}
</span>
)}
</div>
</button>
);
}
// Hook for scroll-into-view behavior
export function useMenuItemRefs(selectedIndex: number) {
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
useEffect(() => {
const selectedItem = itemRefs.current.get(selectedIndex);
if (selectedItem) {
selectedItem.scrollIntoView({ block: 'nearest' });
}
}, [selectedIndex]);
const setItemRef = (index: number) => (el: HTMLButtonElement | null) => {
if (el) {
itemRefs.current.set(index, el);
}
};
return { setItemRef };
}
@@ -1,10 +1,8 @@
'use client';
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { IconCommand } from '@tabler/icons-react';
import { cn } from '@/lib/utils';
import { IconRobot } from '@tabler/icons-react';
import type { SlashCommand } from '@/hooks/use-inline-slash';
import { PopupMenu, PopupMenuItem, useMenuItemRefs } from './popup-menu';
type SlashCommandMenuProps = {
isOpen: boolean;
@@ -16,9 +14,6 @@ type SlashCommandMenuProps = {
setSelectedIndex: (index: number) => void;
};
const MENU_HEIGHT = 280;
const MENU_WIDTH = 320;
export function SlashCommandMenu({
isOpen,
position,
@@ -28,86 +23,32 @@ export function SlashCommandMenu({
onClose,
setSelectedIndex,
}: SlashCommandMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
const { setItemRef } = useMenuItemRefs(selectedIndex);
// Scroll selected item into view
useEffect(() => {
const selectedItem = itemRefs.current.get(selectedIndex);
if (selectedItem) {
selectedItem.scrollIntoView({ block: 'nearest' });
}
}, [selectedIndex]);
// Close on click outside
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen, onClose]);
if (!isOpen || !position || commands.length === 0) {
if (commands.length === 0) {
return null;
}
// Calculate position (above cursor)
const menuStyle: React.CSSProperties = {
position: 'fixed',
left: Math.max(8, Math.min(position.x - 16, window.innerWidth - MENU_WIDTH - 8)),
bottom: window.innerHeight - position.y + 8,
width: MENU_WIDTH,
maxHeight: MENU_HEIGHT,
zIndex: 50,
};
const menu = (
<div
ref={menuRef}
style={menuStyle}
className="overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10"
return (
<PopupMenu
isOpen={isOpen}
position={position}
title="Commands"
selectedIndex={selectedIndex}
onClose={onClose}
>
{/* Header */}
<div className="px-2 py-1.5 border-b border-border/50">
<span className="text-xs font-medium text-muted-foreground">Commands</span>
</div>
{/* Content */}
<div className="overflow-y-auto py-1 scrollbar-thin" style={{ maxHeight: MENU_HEIGHT - 36 }}>
{commands.map((command, index) => (
<button
key={command.id}
ref={(el) => {
if (el) itemRefs.current.set(index, el);
}}
type="button"
className={cn(
'flex w-full cursor-pointer select-none items-center gap-3 rounded-[6px] mx-1 px-2 py-1.5 text-[13px] text-left',
'hover:bg-muted/50',
selectedIndex === index && 'bg-muted/50'
)}
style={{ width: 'calc(100% - 8px)' }}
onClick={() => onSelect(command)}
onMouseEnter={() => setSelectedIndex(index)}
>
<IconCommand className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="min-w-0 flex-1">
<div className="truncate">{command.label}</div>
<div className="text-[11px] text-muted-foreground">{command.description}</div>
</div>
</button>
))}
</div>
</div>
{commands.map((command, index) => (
<PopupMenuItem
key={command.id}
icon={<IconRobot className="h-4 w-4" />}
label={command.label}
description={command.description}
isSelected={selectedIndex === index}
onClick={() => onSelect(command)}
onMouseEnter={() => setSelectedIndex(index)}
itemRef={setItemRef(index)}
/>
))}
</PopupMenu>
);
// Render via portal to escape any overflow containers
if (typeof document === 'undefined') return null;
return createPortal(menu, document.body);
}
+4 -4
View File
@@ -86,9 +86,9 @@ const mermaidCodeBlockView = $view(codeBlockSchema.node, () => {
// Render the mermaid diagram
const id = `mermaid-${++mermaidIdCounter}`;
mermaid.render(id, currentContent).then(({ svg }) => {
mermaid.render(id, currentContent).then(({ svg }: { svg: string }) => {
dom.innerHTML = svg;
}).catch((err) => {
}).catch((err: Error) => {
dom.innerHTML = `<pre class="mermaid-error">Error rendering diagram: ${err.message}</pre>`;
});
@@ -103,9 +103,9 @@ const mermaidCodeBlockView = $view(codeBlockSchema.node, () => {
if (newContent !== currentContent) {
currentContent = newContent; // Update tracked content
const newId = `mermaid-${++mermaidIdCounter}`;
mermaid.render(newId, newContent).then(({ svg }) => {
mermaid.render(newId, newContent).then(({ svg }: { svg: string }) => {
dom.innerHTML = svg;
}).catch((err) => {
}).catch((err: Error) => {
dom.innerHTML = `<pre class="mermaid-error">Error rendering diagram: ${err.message}</pre>`;
});
}
+17
View File
@@ -22,6 +22,9 @@ type Position = {
// Debounce delay for file search (ms)
const FILE_SEARCH_DEBOUNCE = 300;
// Close menu if no results after this many characters
const NO_RESULTS_CLOSE_THRESHOLD = 3;
function isValidMentionTrigger(text: string, pos: number): boolean {
if (pos === 0) return true;
const charBefore = text[pos - 1];
@@ -180,6 +183,20 @@ export function useInlineMention(
}, [filteredItems.length]);
/* eslint-enable react-hooks/set-state-in-effect */
// Auto-close menu when no results and query is long enough
// This prevents showing "No results found" when user is just typing normally
/* eslint-disable react-hooks/set-state-in-effect -- auto-close logic is intentional */
useEffect(() => {
if (!isOpen || isLoading) return;
if (filteredItems.length === 0 && query.length >= NO_RESULTS_CLOSE_THRESHOLD) {
setIsOpen(false);
setTriggerStart(-1);
setQuery('');
}
}, [isOpen, isLoading, filteredItems.length, query.length]);
/* eslint-enable react-hooks/set-state-in-effect */
// Handle text changes to detect @ trigger
const handleChange = useCallback(
(newValue: string) => {
+54 -76
View File
@@ -2,12 +2,17 @@
import { useState, useCallback, useMemo, useEffect } from 'react';
import type { RichTextInputHandle } from '@/components/task/chat/rich-text-input';
import { useAppStore } from '@/components/state-provider';
export type SlashCommandAction = 'agent';
export type SlashCommand = {
id: string;
label: string;
description: string;
action: 'plan' | 'todo' | 'review' | 'summarize';
action: SlashCommandAction;
// For agent commands
agentCommandName?: string;
};
type Position = {
@@ -15,70 +20,79 @@ type Position = {
y: number;
};
const SLASH_COMMANDS: SlashCommand[] = [
{
id: 'plan',
label: '/plan',
description: 'Start a planning response',
action: 'plan',
},
{
id: 'todo',
label: '/todo',
description: 'Add a todo list',
action: 'todo',
},
{
id: 'review',
label: '/review',
description: 'Request a review',
action: 'review',
},
{
id: 'summarize',
label: '/summarize',
description: 'Summarize the task',
action: 'summarize',
},
];
function isValidSlashTrigger(text: string, pos: number): boolean {
if (pos === 0) return true;
const charBefore = text[pos - 1];
return charBefore === ' ' || charBefore === '\n' || charBefore === '\t';
}
function filterCommands(query: string): SlashCommand[] {
if (!query) return SLASH_COMMANDS;
function filterCommands(query: string, allCommands: SlashCommand[]): SlashCommand[] {
if (!query) return allCommands;
const lowerQuery = query.toLowerCase();
return SLASH_COMMANDS.filter((cmd) => {
return allCommands.filter((cmd) => {
const label = cmd.label.toLowerCase();
return label.startsWith('/' + lowerQuery) || cmd.action.startsWith(lowerQuery);
const cmdName = cmd.agentCommandName?.toLowerCase();
return label.startsWith('/' + lowerQuery) || cmdName?.startsWith(lowerQuery);
}).sort((a, b) => {
// Prefer exact prefix matches
const aStartsWithQuery = a.action.toLowerCase().startsWith(lowerQuery);
const bStartsWithQuery = b.action.toLowerCase().startsWith(lowerQuery);
const aName = a.agentCommandName?.toLowerCase();
const bName = b.agentCommandName?.toLowerCase();
const aStartsWithQuery = aName?.startsWith(lowerQuery) ?? false;
const bStartsWithQuery = bName?.startsWith(lowerQuery) ?? false;
if (aStartsWithQuery && !bStartsWithQuery) return -1;
if (!aStartsWithQuery && bStartsWithQuery) return 1;
return 0;
});
}
type UseInlineSlashOptions = {
sessionId?: string | null;
onAgentCommand?: (commandName: string) => void;
};
export function useInlineSlash(
inputRef: React.RefObject<RichTextInputHandle | null>,
value: string,
onChange: (value: string) => void,
onPlanModeChange?: (enabled: boolean) => void
options?: UseInlineSlashOptions
) {
const { sessionId, onAgentCommand } = options ?? {};
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState<Position | null>(null);
const [triggerStart, setTriggerStart] = useState<number>(-1);
const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
// Get agent commands from store
const agentCommands = useAppStore((state) =>
sessionId ? state.availableCommands.bySessionId[sessionId] : undefined
);
// Convert agent commands to slash commands
// Filter out "bundled" commands (skills) that don't produce visible output
const allCommands = useMemo(() => {
if (!agentCommands || agentCommands.length === 0) {
return [];
}
return agentCommands
.filter((cmd) => {
// Skip bundled skills - they don't produce <local-command-stdout> output
const desc = cmd.description || '';
return !desc.includes('(bundled)');
})
.map((cmd) => ({
id: `agent-${cmd.name}`,
label: `/${cmd.name}`,
description: cmd.description || `Run /${cmd.name} command`,
action: 'agent' as const,
agentCommandName: cmd.name,
}));
}, [agentCommands]);
// Filter commands based on query
const filteredCommands = useMemo(() => filterCommands(query), [query]);
const filteredCommands = useMemo(() => filterCommands(query, allCommands), [query, allCommands]);
// Reset selected index when commands change
/* eslint-disable react-hooks/set-state-in-effect -- resetting selection on items change is intentional */
@@ -143,45 +157,9 @@ export function useInlineSlash(
const newValue = value.substring(0, triggerStart) + value.substring(cursorPos);
onChange(newValue);
// Execute the command action
switch (command.action) {
case 'plan':
onPlanModeChange?.(true);
break;
case 'todo':
// Insert todo template
const todoText = '## TODO\n- [ ] ';
const todoValue = value.substring(0, triggerStart) + todoText + value.substring(cursorPos);
onChange(todoValue);
requestAnimationFrame(() => {
const newPos = triggerStart + todoText.length;
input.setSelectionRange(newPos, newPos);
input.focus();
});
break;
case 'review':
// Insert review request
const reviewText = 'Please review ';
const reviewValue = value.substring(0, triggerStart) + reviewText + value.substring(cursorPos);
onChange(reviewValue);
requestAnimationFrame(() => {
const newPos = triggerStart + reviewText.length;
input.setSelectionRange(newPos, newPos);
input.focus();
});
break;
case 'summarize':
// Insert summarize request
const summaryText = 'Please summarize ';
const summaryValue =
value.substring(0, triggerStart) + summaryText + value.substring(cursorPos);
onChange(summaryValue);
requestAnimationFrame(() => {
const newPos = triggerStart + summaryText.length;
input.setSelectionRange(newPos, newPos);
input.focus();
});
break;
// Execute the command action - only agent commands are supported now
if (command.agentCommandName && onAgentCommand) {
onAgentCommand(command.agentCommandName);
}
setIsOpen(false);
@@ -193,7 +171,7 @@ export function useInlineSlash(
input.focus();
});
},
[inputRef, triggerStart, value, onChange, onPlanModeChange]
[inputRef, triggerStart, value, onChange, onAgentCommand]
);
// Handle keyboard navigation
+2
View File
@@ -73,6 +73,8 @@ export type {
ContextWindowEntry,
ContextWindowState,
AgentState,
AvailableCommand,
AvailableCommandsState,
} from './session-runtime/types';
export type {
PreviewStage,
@@ -25,6 +25,7 @@ export const defaultSessionRuntimeState: SessionRuntimeSliceState = {
sessionCommits: { bySessionId: {}, loading: {} },
contextWindow: { bySessionId: {} },
agents: { agents: [] },
availableCommands: { bySessionId: {} },
};
export const createSessionRuntimeSlice: StateCreator<
@@ -129,4 +130,13 @@ export const createSessionRuntimeSlice: StateCreator<
// Delete the entry so hook will refetch (undefined triggers fetch, [] does not)
delete draft.sessionCommits.bySessionId[sessionId];
}),
// Available commands actions
setAvailableCommands: (sessionId, commands) =>
set((draft) => {
draft.availableCommands.bySessionId[sessionId] = commands;
}),
clearAvailableCommands: (sessionId) =>
set((draft) => {
delete draft.availableCommands.bySessionId[sessionId];
}),
});
@@ -137,6 +137,15 @@ export type AgentState = {
agents: Array<{ id: string; status: 'idle' | 'running' | 'error' }>;
};
export type AvailableCommand = {
name: string;
description?: string;
};
export type AvailableCommandsState = {
bySessionId: Record<string, AvailableCommand[]>;
};
export type SessionRuntimeSliceState = {
terminal: TerminalState;
shell: ShellState;
@@ -146,6 +155,7 @@ export type SessionRuntimeSliceState = {
sessionCommits: SessionCommitsState;
contextWindow: ContextWindowState;
agents: AgentState;
availableCommands: AvailableCommandsState;
};
export type SessionRuntimeSliceActions = {
@@ -172,6 +182,9 @@ export type SessionRuntimeSliceActions = {
setSessionCommitsLoading: (sessionId: string, loading: boolean) => void;
addSessionCommit: (sessionId: string, commit: SessionCommit) => void;
clearSessionCommits: (sessionId: string) => void;
// Available commands actions
setAvailableCommands: (sessionId: string, commands: AvailableCommand[]) => void;
clearAvailableCommands: (sessionId: string) => void;
};
export type SessionRuntimeSlice = SessionRuntimeSliceState & SessionRuntimeSliceActions;
+5
View File
@@ -137,6 +137,7 @@ export type AppState = {
sessionCommits: typeof defaultSessionRuntimeState['sessionCommits'];
contextWindow: typeof defaultSessionRuntimeState['contextWindow'];
agents: typeof defaultSessionRuntimeState['agents'];
availableCommands: typeof defaultSessionRuntimeState['availableCommands'];
// UI slice
previewPanel: typeof defaultUIState['previewPanel'];
@@ -237,6 +238,9 @@ export type AppState = {
setTaskPlanLoading: (taskId: string, loading: boolean) => void;
setTaskPlanSaving: (taskId: string, saving: boolean) => void;
clearTaskPlan: (taskId: string) => void;
// Available commands actions
setAvailableCommands: (sessionId: string, commands: import('./slices/session-runtime/types').AvailableCommand[]) => void;
clearAvailableCommands: (sessionId: string) => void;
};
export type AppStore = ReturnType<typeof createAppStore>;
@@ -277,6 +281,7 @@ const defaultState = {
sessionCommits: defaultSessionRuntimeState.sessionCommits,
contextWindow: defaultSessionRuntimeState.contextWindow,
agents: defaultSessionRuntimeState.agents,
availableCommands: defaultSessionRuntimeState.availableCommands,
previewPanel: defaultUIState.previewPanel,
rightPanel: defaultUIState.rightPanel,
diffs: defaultUIState.diffs,
+15
View File
@@ -31,6 +31,7 @@ export type BackendMessageType =
| 'session.agentctl_error'
| 'session.turn.started'
| 'session.turn.completed'
| 'session.available_commands'
| 'executor.created'
| 'executor.updated'
| 'executor.deleted'
@@ -297,6 +298,19 @@ export type TurnEventPayload = {
updated_at: string;
};
export type AvailableCommandPayload = {
name: string;
description?: string;
};
export type AvailableCommandsPayload = {
task_id: string;
session_id: string;
agent_id: string;
available_commands: AvailableCommandPayload[];
timestamp: string;
};
export type TaskPlanEventPayload = {
id: string;
task_id: string;
@@ -340,6 +354,7 @@ export type BackendMessageMap = {
'session.agentctl_error': BackendMessage<'session.agentctl_error', TaskSessionAgentctlPayload>;
'session.turn.started': BackendMessage<'session.turn.started', TurnEventPayload>;
'session.turn.completed': BackendMessage<'session.turn.completed', TurnEventPayload>;
'session.available_commands': BackendMessage<'session.available_commands', AvailableCommandsPayload>;
'executor.created': BackendMessage<'executor.created', ExecutorPayload>;
'executor.updated': BackendMessage<'executor.updated', ExecutorPayload>;
'executor.deleted': BackendMessage<'executor.deleted', ExecutorPayload>;
@@ -0,0 +1,19 @@
import type { StoreApi } from 'zustand';
import type { AppState } from '@/lib/state/store';
import type { WsHandlers } from '@/lib/ws/handlers/types';
import type { AvailableCommand } from '@/lib/state/slices/session-runtime/types';
export function registerAvailableCommandsHandlers(store: StoreApi<AppState>): WsHandlers {
return {
'session.available_commands': (message) => {
const payload = message.payload;
if (!payload?.session_id) {
return;
}
const sessionId = payload.session_id as string;
const commands = (payload.available_commands || []) as AvailableCommand[];
store.getState().setAvailableCommands(sessionId, commands);
},
};
}
+2
View File
@@ -2,6 +2,7 @@ import type { StoreApi } from 'zustand';
import type { AppState } from '@/lib/state/store';
import { registerAgentsHandlers } from '@/lib/ws/handlers/agents';
import { registerTaskSessionHandlers } from '@/lib/ws/handlers/agent-session';
import { registerAvailableCommandsHandlers } from '@/lib/ws/handlers/available-commands';
import { registerBoardsHandlers } from '@/lib/ws/handlers/boards';
import { registerMessagesHandlers } from '@/lib/ws/handlers/messages';
@@ -31,6 +32,7 @@ export function registerWsHandlers(store: StoreApi<AppState>) {
...registerEnvironmentsHandlers(store),
...registerAgentsHandlers(store),
...registerTaskSessionHandlers(store),
...registerAvailableCommandsHandlers(store),
...registerUsersHandlers(store),
...registerTerminalsHandlers(store),
...registerDiffsHandlers(store),