feat: slash commands from agents (#136)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>`;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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),
|
||||
|
||||
Reference in New Issue
Block a user