From bbfa60c787f2ec459f1689b9a650ddbec9693ed9 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 16 Apr 2025 20:06:23 +0200 Subject: reimplement agent,provider and add file history --- internal/pubsub/broker.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'internal/pubsub') diff --git a/internal/pubsub/broker.go b/internal/pubsub/broker.go index 633a6d57f..3e70ae095 100644 --- a/internal/pubsub/broker.go +++ b/internal/pubsub/broker.go @@ -5,7 +5,7 @@ import ( "sync" ) -const bufferSize = 1024 * 1024 +const bufferSize = 1024 type Logger interface { Debug(msg string, args ...any) -- cgit v1.2.3 From 2de51274177432b559be3b7deb1f14b9539f2994 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Sat, 19 Apr 2025 16:35:45 +0200 Subject: initial tool call stream --- internal/llm/agent/agent.go | 22 ++++++ internal/llm/provider/anthropic.go | 60 ++++++++++++---- internal/llm/provider/openai.go | 9 +-- internal/llm/provider/provider.go | 7 +- internal/message/content.go | 43 ++++++++++++ internal/message/message.go | 2 + internal/pubsub/broker.go | 7 -- internal/tui/components/chat/list.go | 117 +++++++------------------------- internal/tui/components/chat/message.go | 92 ++++++++++++++++++++----- internal/tui/layout/split.go | 28 ++++++++ internal/tui/page/chat.go | 10 ++- 11 files changed, 261 insertions(+), 136 deletions(-) (limited to 'internal/pubsub') diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go index 7542d9adf..ae5bcb231 100644 --- a/internal/llm/agent/agent.go +++ b/internal/llm/agent/agent.go @@ -380,6 +380,21 @@ func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg case provider.EventContentDelta: assistantMsg.AppendContent(event.Content) return a.messages.Update(ctx, *assistantMsg) + case provider.EventToolUseStart: + assistantMsg.AddToolCall(*event.ToolCall) + return a.messages.Update(ctx, *assistantMsg) + // TODO: see how to handle this + // case provider.EventToolUseDelta: + // tm := time.Unix(assistantMsg.UpdatedAt, 0) + // assistantMsg.AppendToolCallInput(event.ToolCall.ID, event.ToolCall.Input) + // if time.Since(tm) > 1000*time.Millisecond { + // err := a.messages.Update(ctx, *assistantMsg) + // assistantMsg.UpdatedAt = time.Now().Unix() + // return err + // } + case provider.EventToolUseStop: + assistantMsg.FinishToolCall(event.ToolCall.ID) + return a.messages.Update(ctx, *assistantMsg) case provider.EventError: if errors.Is(event.Error, context.Canceled) { logging.InfoPersist(fmt.Sprintf("Event processing canceled for session: %s", sessionID)) @@ -456,6 +471,13 @@ func createAgentProvider(agentName config.AgentName) (provider.Provider, error) provider.WithReasoningEffort(agentConfig.ReasoningEffort), ), ) + } else if model.Provider == models.ProviderAnthropic && model.CanReason { + opts = append( + opts, + provider.WithAnthropicOptions( + provider.WithAnthropicShouldThinkFn(provider.DefaultShouldThinkFn), + ), + ) } agentProvider, err := provider.NewProvider( model.Provider, diff --git a/internal/llm/provider/anthropic.go b/internal/llm/provider/anthropic.go index 7bbc02103..2c16a0593 100644 --- a/internal/llm/provider/anthropic.go +++ b/internal/llm/provider/anthropic.go @@ -93,8 +93,7 @@ func (a *anthropicClient) convertMessages(messages []message.Message) (anthropic } if len(blocks) == 0 { - logging.Warn("There is a message without content, investigate") - // This should never happend but we log this because we might have a bug in our cleanup method + logging.Warn("There is a message without content, investigate, this should not happen") continue } anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...)) @@ -196,8 +195,8 @@ func (a *anthropicClient) send(ctx context.Context, messages []message.Message, preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools)) cfg := config.Get() if cfg.Debug { - jsonData, _ := json.Marshal(preparedMessages) - logging.Debug("Prepared messages", "messages", string(jsonData)) + // jsonData, _ := json.Marshal(preparedMessages) + // logging.Debug("Prepared messages", "messages", string(jsonData)) } attempts := 0 for { @@ -243,8 +242,8 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools)) cfg := config.Get() if cfg.Debug { - jsonData, _ := json.Marshal(preparedMessages) - logging.Debug("Prepared messages", "messages", string(jsonData)) + // jsonData, _ := json.Marshal(preparedMessages) + // logging.Debug("Prepared messages", "messages", string(jsonData)) } attempts := 0 eventChan := make(chan ProviderEvent) @@ -257,6 +256,7 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message ) accumulatedMessage := anthropic.Message{} + currentToolCallID := "" for anthropicStream.Next() { event := anthropicStream.Current() err := accumulatedMessage.Accumulate(event) @@ -267,7 +267,19 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message switch event := event.AsAny().(type) { case anthropic.ContentBlockStartEvent: - eventChan <- ProviderEvent{Type: EventContentStart} + if event.ContentBlock.Type == "text" { + eventChan <- ProviderEvent{Type: EventContentStart} + } else if event.ContentBlock.Type == "tool_use" { + currentToolCallID = event.ContentBlock.ID + eventChan <- ProviderEvent{ + Type: EventToolUseStart, + ToolCall: &message.ToolCall{ + ID: event.ContentBlock.ID, + Name: event.ContentBlock.Name, + Finished: false, + }, + } + } case anthropic.ContentBlockDeltaEvent: if event.Delta.Type == "thinking_delta" && event.Delta.Thinking != "" { @@ -280,11 +292,30 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message Type: EventContentDelta, Content: event.Delta.Text, } + } else if event.Delta.Type == "input_json_delta" { + if currentToolCallID != "" { + eventChan <- ProviderEvent{ + Type: EventToolUseDelta, + ToolCall: &message.ToolCall{ + ID: currentToolCallID, + Finished: false, + Input: event.Delta.JSON.PartialJSON.Raw(), + }, + } + } } - // TODO: check if we can somehow stream tool calls - case anthropic.ContentBlockStopEvent: - eventChan <- ProviderEvent{Type: EventContentStop} + if currentToolCallID != "" { + eventChan <- ProviderEvent{ + Type: EventToolUseStop, + ToolCall: &message.ToolCall{ + ID: currentToolCallID, + }, + } + currentToolCallID = "" + } else { + eventChan <- ProviderEvent{Type: EventContentStop} + } case anthropic.MessageStopEvent: content := "" @@ -378,10 +409,11 @@ func (a *anthropicClient) toolCalls(msg anthropic.Message) []message.ToolCall { switch variant := block.AsAny().(type) { case anthropic.ToolUseBlock: toolCall := message.ToolCall{ - ID: variant.ID, - Name: variant.Name, - Input: string(variant.Input), - Type: string(variant.Type), + ID: variant.ID, + Name: variant.Name, + Input: string(variant.Input), + Type: string(variant.Type), + Finished: true, } toolCalls = append(toolCalls, toolCall) } diff --git a/internal/llm/provider/openai.go b/internal/llm/provider/openai.go index 6c6f74988..40d263242 100644 --- a/internal/llm/provider/openai.go +++ b/internal/llm/provider/openai.go @@ -344,10 +344,11 @@ func (o *openaiClient) toolCalls(completion openai.ChatCompletion) []message.Too if len(completion.Choices) > 0 && len(completion.Choices[0].Message.ToolCalls) > 0 { for _, call := range completion.Choices[0].Message.ToolCalls { toolCall := message.ToolCall{ - ID: call.ID, - Name: call.Function.Name, - Input: call.Function.Arguments, - Type: "function", + ID: call.ID, + Name: call.Function.Name, + Input: call.Function.Arguments, + Type: "function", + Finished: true, } toolCalls = append(toolCalls, toolCall) } diff --git a/internal/llm/provider/provider.go b/internal/llm/provider/provider.go index e04bee71b..283a0d983 100644 --- a/internal/llm/provider/provider.go +++ b/internal/llm/provider/provider.go @@ -15,6 +15,9 @@ const maxRetries = 8 const ( EventContentStart EventType = "content_start" + EventToolUseStart EventType = "tool_use_start" + EventToolUseDelta EventType = "tool_use_delta" + EventToolUseStop EventType = "tool_use_stop" EventContentDelta EventType = "content_delta" EventThinkingDelta EventType = "thinking_delta" EventContentStop EventType = "content_stop" @@ -43,8 +46,8 @@ type ProviderEvent struct { Content string Thinking string Response *ProviderResponse - - Error error + ToolCall *message.ToolCall + Error error } type Provider interface { SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) diff --git a/internal/message/content.go b/internal/message/content.go index f52449f4a..beebe354e 100644 --- a/internal/message/content.go +++ b/internal/message/content.go @@ -233,6 +233,40 @@ func (m *Message) AppendReasoningContent(delta string) { } } +func (m *Message) FinishToolCall(toolCallID string) { + for i, part := range m.Parts { + if c, ok := part.(ToolCall); ok { + if c.ID == toolCallID { + m.Parts[i] = ToolCall{ + ID: c.ID, + Name: c.Name, + Input: c.Input, + Type: c.Type, + Finished: true, + } + return + } + } + } +} + +func (m *Message) AppendToolCallInput(toolCallID string, inputDelta string) { + for i, part := range m.Parts { + if c, ok := part.(ToolCall); ok { + if c.ID == toolCallID { + m.Parts[i] = ToolCall{ + ID: c.ID, + Name: c.Name, + Input: c.Input + inputDelta, + Type: c.Type, + Finished: c.Finished, + } + return + } + } + } +} + func (m *Message) AddToolCall(tc ToolCall) { for i, part := range m.Parts { if c, ok := part.(ToolCall); ok { @@ -246,6 +280,15 @@ func (m *Message) AddToolCall(tc ToolCall) { } func (m *Message) SetToolCalls(tc []ToolCall) { + // remove any existing tool call part it could have multiple + parts := make([]ContentPart, 0) + for _, part := range m.Parts { + if _, ok := part.(ToolCall); ok { + continue + } + parts = append(parts, part) + } + m.Parts = parts for _, toolCall := range tc { m.Parts = append(m.Parts, toolCall) } diff --git a/internal/message/message.go b/internal/message/message.go index f165fcfc7..20ace7b41 100644 --- a/internal/message/message.go +++ b/internal/message/message.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "fmt" + "time" "github.com/google/uuid" "github.com/kujtimiihoxha/opencode/internal/db" @@ -116,6 +117,7 @@ func (s *service) Update(ctx context.Context, message Message) error { if err != nil { return err } + message.UpdatedAt = time.Now().Unix() s.Publish(pubsub.UpdatedEvent, message) return nil } diff --git a/internal/pubsub/broker.go b/internal/pubsub/broker.go index 3e70ae095..d73accffb 100644 --- a/internal/pubsub/broker.go +++ b/internal/pubsub/broker.go @@ -7,13 +7,6 @@ import ( const bufferSize = 1024 -type Logger interface { - Debug(msg string, args ...any) - Info(msg string, args ...any) - Warn(msg string, args ...any) - Error(msg string, args ...any) -} - // Broker allows clients to publish events and subscribe to events type Broker[T any] struct { subs map[chan Event[T]]struct{} // subscriptions diff --git a/internal/tui/components/chat/list.go b/internal/tui/components/chat/list.go index 994ddea03..b09cc4495 100644 --- a/internal/tui/components/chat/list.go +++ b/internal/tui/components/chat/list.go @@ -4,8 +4,6 @@ import ( "context" "fmt" "math" - "sync" - "time" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/spinner" @@ -13,7 +11,6 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/kujtimiihoxha/opencode/internal/app" - "github.com/kujtimiihoxha/opencode/internal/logging" "github.com/kujtimiihoxha/opencode/internal/message" "github.com/kujtimiihoxha/opencode/internal/pubsub" "github.com/kujtimiihoxha/opencode/internal/session" @@ -35,89 +32,14 @@ type messagesCmp struct { messages []message.Message uiMessages []uiMessage currentMsgID string - mutex sync.Mutex cachedContent map[string]cacheItem spinner spinner.Model - lastUpdate time.Time rendering bool } type renderFinishedMsg struct{} func (m *messagesCmp) Init() tea.Cmd { - return tea.Batch(m.viewport.Init()) -} - -func (m *messagesCmp) preloadSessions() tea.Cmd { - return func() tea.Msg { - m.mutex.Lock() - defer m.mutex.Unlock() - sessions, err := m.app.Sessions.List(context.Background()) - if err != nil { - return util.ReportError(err)() - } - if len(sessions) == 0 { - return nil - } - if len(sessions) > 20 { - sessions = sessions[:20] - } - for _, s := range sessions { - messages, err := m.app.Messages.List(context.Background(), s.ID) - if err != nil { - return util.ReportError(err)() - } - if len(messages) == 0 { - continue - } - m.cacheSessionMessages(messages, m.width) - - } - logging.Debug("preloaded sessions") - - return func() tea.Msg { - return renderFinishedMsg{} - } - } -} - -func (m *messagesCmp) cacheSessionMessages(messages []message.Message, width int) { - pos := 0 - if m.width == 0 { - return - } - for inx, msg := range messages { - switch msg.Role { - case message.User: - userMsg := renderUserMessage( - msg, - false, - width, - pos, - ) - m.cachedContent[msg.ID] = cacheItem{ - width: width, - content: []uiMessage{userMsg}, - } - pos += userMsg.height + 1 // + 1 for spacing - case message.Assistant: - assistantMessages := renderAssistantMessage( - msg, - inx, - messages, - m.app.Messages, - "", - width, - pos, - ) - for _, msg := range assistantMessages { - pos += msg.height + 1 // + 1 for spacing - } - m.cachedContent[msg.ID] = cacheItem{ - width: width, - content: assistantMessages, - } - } - } + return tea.Batch(m.viewport.Init(), m.spinner.Tick) } func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -360,21 +282,35 @@ func hasToolsWithoutResponse(messages []message.Message) bool { break } } - if !found { + if !found && v.Finished { return true } } + return false +} +func hasUnfinishedToolCalls(messages []message.Message) bool { + toolCalls := make([]message.ToolCall, 0) + for _, m := range messages { + toolCalls = append(toolCalls, m.ToolCalls()...) + } + for _, v := range toolCalls { + if !v.Finished { + return true + } + } return false } func (m *messagesCmp) working() string { text := "" - if m.IsAgentWorking() { + if m.IsAgentWorking() && len(m.messages) > 0 { task := "Thinking..." lastMessage := m.messages[len(m.messages)-1] if hasToolsWithoutResponse(m.messages) { task = "Waiting for tool response..." + } else if hasUnfinishedToolCalls(m.messages) { + task = "Building tool call..." } else if !lastMessage.IsFinished() { task = "Generating..." } @@ -434,8 +370,7 @@ func (m *messagesCmp) SetSize(width, height int) tea.Cmd { delete(m.cachedContent, msg.ID) } m.uiMessages = make([]uiMessage, 0) - m.renderView() - return m.preloadSessions() + return nil } func (m *messagesCmp) GetSize() (int, int) { @@ -446,16 +381,16 @@ func (m *messagesCmp) SetSession(session session.Session) tea.Cmd { if m.session.ID == session.ID { return nil } + m.session = session + messages, err := m.app.Messages.List(context.Background(), session.ID) + if err != nil { + return util.ReportError(err) + } + m.messages = messages + m.currentMsgID = m.messages[len(m.messages)-1].ID + delete(m.cachedContent, m.currentMsgID) m.rendering = true return func() tea.Msg { - m.session = session - messages, err := m.app.Messages.List(context.Background(), session.ID) - if err != nil { - return util.ReportError(err) - } - m.messages = messages - m.currentMsgID = m.messages[len(m.messages)-1].ID - delete(m.cachedContent, m.currentMsgID) m.renderView() return renderFinishedMsg{} } diff --git a/internal/tui/components/chat/message.go b/internal/tui/components/chat/message.go index 14b9e268e..b8e450079 100644 --- a/internal/tui/components/chat/message.go +++ b/internal/tui/components/chat/message.go @@ -113,18 +113,10 @@ func renderAssistantMessage( width int, position int, ) []uiMessage { - // find the user message that is before this assistant message - var userMsg message.Message - for i := msgIndex - 1; i >= 0; i-- { - msg := allMessages[i] - if msg.Role == message.User { - userMsg = allMessages[i] - break - } - } - messages := []uiMessage{} content := msg.Content().String() + thinking := msg.IsThinking() + thinkingContent := msg.ReasoningContent().Thinking finished := msg.IsFinished() finishData := msg.FinishPart() info := []string{} @@ -133,7 +125,7 @@ func renderAssistantMessage( if finished { switch finishData.Reason { case message.FinishReasonEndTurn: - took := formatTimeDifference(userMsg.CreatedAt, finishData.Time) + took := formatTimeDifference(msg.CreatedAt, finishData.Time) info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render( fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, took), )) @@ -166,6 +158,9 @@ func renderAssistantMessage( }) position += messages[0].height position++ // for the space + } else if thinking && thinkingContent != "" { + // Render the thinking content + content = renderMessage(thinkingContent, false, msg.ID == focusedUIMessageId, width) } for i, toolCall := range msg.ToolCalls() { @@ -218,10 +213,40 @@ func toolName(name string) string { return "View" case tools.WriteToolName: return "Write" + case tools.PatchToolName: + return "Patch" } return name } +func getToolAction(name string) string { + switch name { + case agent.AgentToolName: + return "Preparing prompt..." + case tools.BashToolName: + return "Building command..." + case tools.EditToolName: + return "Preparing edit..." + case tools.FetchToolName: + return "Writing fetch..." + case tools.GlobToolName: + return "Finding files..." + case tools.GrepToolName: + return "Searching content..." + case tools.LSToolName: + return "Listing directory..." + case tools.SourcegraphToolName: + return "Searching code..." + case tools.ViewToolName: + return "Reading file..." + case tools.WriteToolName: + return "Preparing write..." + case tools.PatchToolName: + return "Preparing patch..." + } + return "Working..." +} + // renders params, params[0] (params[1]=params[2] ....) func renderParams(paramsWidth int, params ...string) string { if len(params) == 0 { @@ -490,8 +515,47 @@ func renderToolMessage( if nested { width = width - 3 } + style := styles.BaseStyle. + Width(width - 1). + BorderLeft(true). + BorderStyle(lipgloss.ThickBorder()). + PaddingLeft(1). + BorderForeground(styles.ForgroundDim) + response := findToolResponse(toolCall.ID, allMessages) toolName := styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf("%s: ", toolName(toolCall.Name))) + + if !toolCall.Finished { + // Get a brief description of what the tool is doing + toolAction := getToolAction(toolCall.Name) + + // toolInput := strings.ReplaceAll(toolCall.Input, "\n", " ") + // truncatedInput := toolInput + // if len(truncatedInput) > 10 { + // truncatedInput = truncatedInput[len(truncatedInput)-10:] + // } + // + // truncatedInput = styles.BaseStyle. + // Italic(true). + // Width(width - 2 - lipgloss.Width(toolName)). + // Background(styles.BackgroundDim). + // Foreground(styles.ForgroundMid). + // Render(truncatedInput) + + progressText := styles.BaseStyle. + Width(width - 2 - lipgloss.Width(toolName)). + Foreground(styles.ForgroundDim). + Render(fmt.Sprintf("%s", toolAction)) + + content := style.Render(lipgloss.JoinHorizontal(lipgloss.Left, toolName, progressText)) + toolMsg := uiMessage{ + messageType: toolMessageType, + position: position, + height: lipgloss.Height(content), + content: content, + } + return toolMsg + } params := renderToolParams(width-2-lipgloss.Width(toolName), toolCall) responseContent := "" if response != nil { @@ -504,12 +568,6 @@ func renderToolMessage( Foreground(styles.ForgroundDim). Render("Waiting for response...") } - style := styles.BaseStyle. - Width(width - 1). - BorderLeft(true). - BorderStyle(lipgloss.ThickBorder()). - PaddingLeft(1). - BorderForeground(styles.ForgroundDim) parts := []string{} if !nested { diff --git a/internal/tui/layout/split.go b/internal/tui/layout/split.go index a41df6ab8..f3ab9247d 100644 --- a/internal/tui/layout/split.go +++ b/internal/tui/layout/split.go @@ -14,6 +14,10 @@ type SplitPaneLayout interface { SetLeftPanel(panel Container) tea.Cmd SetRightPanel(panel Container) tea.Cmd SetBottomPanel(panel Container) tea.Cmd + + ClearLeftPanel() tea.Cmd + ClearRightPanel() tea.Cmd + ClearBottomPanel() tea.Cmd } type splitPaneLayout struct { @@ -192,6 +196,30 @@ func (s *splitPaneLayout) SetBottomPanel(panel Container) tea.Cmd { return nil } +func (s *splitPaneLayout) ClearLeftPanel() tea.Cmd { + s.leftPanel = nil + if s.width > 0 && s.height > 0 { + return s.SetSize(s.width, s.height) + } + return nil +} + +func (s *splitPaneLayout) ClearRightPanel() tea.Cmd { + s.rightPanel = nil + if s.width > 0 && s.height > 0 { + return s.SetSize(s.width, s.height) + } + return nil +} + +func (s *splitPaneLayout) ClearBottomPanel() tea.Cmd { + s.bottomPanel = nil + if s.width > 0 && s.height > 0 { + return s.SetSize(s.width, s.height) + } + return nil +} + func (s *splitPaneLayout) BindingKeys() []key.Binding { keys := []key.Binding{} if s.leftPanel != nil { diff --git a/internal/tui/page/chat.go b/internal/tui/page/chat.go index ef826e9a3..a5a656a22 100644 --- a/internal/tui/page/chat.go +++ b/internal/tui/page/chat.go @@ -57,6 +57,14 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if cmd != nil { return p, cmd } + case chat.SessionSelectedMsg: + if p.session.ID == "" { + cmd := p.setSidebar() + if cmd != nil { + cmds = append(cmds, cmd) + } + } + p.session = msg case chat.EditorFocusMsg: p.editingMode = bool(msg) case tea.KeyMsg: @@ -91,7 +99,7 @@ func (p *chatPage) setSidebar() tea.Cmd { } func (p *chatPage) clearSidebar() tea.Cmd { - return p.layout.SetRightPanel(nil) + return p.layout.ClearRightPanel() } func (p *chatPage) sendMessage(text string) tea.Cmd { -- cgit v1.2.3 From e7bb99baab5e6968ce0351d6ad219ed21ceec4df Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 21 Apr 2025 13:33:51 +0200 Subject: fix the memory bug --- README.md | 7 +++- cmd/root.go | 24 ++++++------ internal/pubsub/broker.go | 72 ++++++++++++++++++++++------------ internal/tui/components/chat/list.go | 1 + internal/tui/components/core/status.go | 15 +++++-- internal/tui/tui.go | 63 +++++++++++++++++++++++------ 6 files changed, 127 insertions(+), 55 deletions(-) (limited to 'internal/pubsub') diff --git a/README.md b/README.md index ef55b6929..075114fc3 100644 --- a/README.md +++ b/README.md @@ -351,9 +351,12 @@ go build -o opencode ## Acknowledgments -OpenCode builds upon the work of several open source projects and developers: +OpenCode gratefully acknowledges the contributions and support from these key individuals: -- [@isaacphi](https://github.com/isaacphi) - LSP client implementation +- [@isaacphi](https://github.com/isaacphi) - For the [mcp-language-server](https://github.com/isaacphi/mcp-language-server) project which provided the foundation for our LSP client implementation +- [@adamdottv](https://github.com/adamdottv) - For the design direction and UI/UX architecture + +Special thanks to the broader open source community whose tools and libraries have made this project possible. ## License diff --git a/cmd/root.go b/cmd/root.go index f506e9940..54280ecaa 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -79,7 +79,7 @@ var rootCmd = &cobra.Command{ initMCPTools(ctx, app) // Setup the subscriptions, this will send services events to the TUI - ch, cancelSubs := setupSubscriptions(app) + ch, cancelSubs := setupSubscriptions(app, ctx) // Create a context for the TUI message handler tuiCtx, tuiCancel := context.WithCancel(ctx) @@ -174,21 +174,21 @@ func setupSubscriber[T any]( defer wg.Done() defer logging.RecoverPanic(fmt.Sprintf("subscription-%s", name), nil) + subCh := subscriber(ctx) + for { select { - case event, ok := <-subscriber(ctx): + case event, ok := <-subCh: if !ok { logging.Info("%s subscription channel closed", name) return } - // Convert generic event to tea.Msg if needed var msg tea.Msg = event - // Non-blocking send with timeout to prevent deadlocks select { case outputCh <- msg: - case <-time.After(500 * time.Millisecond): + case <-time.After(2 * time.Second): logging.Warn("%s message dropped due to slow consumer", name) case <-ctx.Done(): logging.Info("%s subscription cancelled", name) @@ -202,23 +202,21 @@ func setupSubscriber[T any]( }() } -func setupSubscriptions(app *app.App) (chan tea.Msg, func()) { +func setupSubscriptions(app *app.App, parentCtx context.Context) (chan tea.Msg, func()) { ch := make(chan tea.Msg, 100) - // Add a buffer to prevent blocking + wg := sync.WaitGroup{} - ctx, cancel := context.WithCancel(context.Background()) - // Setup each subscription using the helper + ctx, cancel := context.WithCancel(parentCtx) // Inherit from parent context + setupSubscriber(ctx, &wg, "logging", logging.Subscribe, ch) setupSubscriber(ctx, &wg, "sessions", app.Sessions.Subscribe, ch) setupSubscriber(ctx, &wg, "messages", app.Messages.Subscribe, ch) setupSubscriber(ctx, &wg, "permissions", app.Permissions.Subscribe, ch) - // Return channel and a cleanup function cleanupFunc := func() { logging.Info("Cancelling all subscriptions") cancel() // Signal all goroutines to stop - // Wait with a timeout for all goroutines to complete waitCh := make(chan struct{}) go func() { defer logging.RecoverPanic("subscription-cleanup", nil) @@ -229,11 +227,11 @@ func setupSubscriptions(app *app.App) (chan tea.Msg, func()) { select { case <-waitCh: logging.Info("All subscription goroutines completed successfully") + close(ch) // Only close after all writers are confirmed done case <-time.After(5 * time.Second): logging.Warn("Timed out waiting for some subscription goroutines to complete") + close(ch) } - - close(ch) // Safe to close after all writers are done or timed out } return ch, cleanupFunc } diff --git a/internal/pubsub/broker.go b/internal/pubsub/broker.go index d73accffb..0de1be063 100644 --- a/internal/pubsub/broker.go +++ b/internal/pubsub/broker.go @@ -5,47 +5,53 @@ import ( "sync" ) -const bufferSize = 1024 +const bufferSize = 64 -// Broker allows clients to publish events and subscribe to events type Broker[T any] struct { - subs map[chan Event[T]]struct{} // subscriptions - mu sync.Mutex // sync access to map - done chan struct{} // close when broker is shutting down + subs map[chan Event[T]]struct{} + mu sync.RWMutex + done chan struct{} + subCount int + maxEvents int } -// NewBroker constructs a pub/sub broker. func NewBroker[T any]() *Broker[T] { + return NewBrokerWithOptions[T](bufferSize, 1000) +} + +func NewBrokerWithOptions[T any](channelBufferSize, maxEvents int) *Broker[T] { b := &Broker[T]{ - subs: make(map[chan Event[T]]struct{}), - done: make(chan struct{}), + subs: make(map[chan Event[T]]struct{}), + done: make(chan struct{}), + subCount: 0, + maxEvents: maxEvents, } return b } -// Shutdown the broker, terminating any subscriptions. func (b *Broker[T]) Shutdown() { - close(b.done) + select { + case <-b.done: // Already closed + return + default: + close(b.done) + } b.mu.Lock() defer b.mu.Unlock() - // Remove each subscriber entry, so Publish() cannot send any further - // messages, and close each subscriber's channel, so the subscriber cannot - // consume any more messages. for ch := range b.subs { delete(b.subs, ch) close(ch) } + + b.subCount = 0 } -// Subscribe subscribes the caller to a stream of events. The returned channel -// is closed when the broker is shutdown. func (b *Broker[T]) Subscribe(ctx context.Context) <-chan Event[T] { b.mu.Lock() defer b.mu.Unlock() - // Check if broker has shutdown and if so return closed channel select { case <-b.done: ch := make(chan Event[T]) @@ -54,18 +60,16 @@ func (b *Broker[T]) Subscribe(ctx context.Context) <-chan Event[T] { default: } - // Subscribe sub := make(chan Event[T], bufferSize) b.subs[sub] = struct{}{} + b.subCount++ - // Unsubscribe when context is done. go func() { <-ctx.Done() b.mu.Lock() defer b.mu.Unlock() - // Check if broker has shutdown and if so do nothing select { case <-b.done: return @@ -74,21 +78,39 @@ func (b *Broker[T]) Subscribe(ctx context.Context) <-chan Event[T] { delete(b.subs, sub) close(sub) + b.subCount-- }() return sub } -// Publish an event to subscribers. +func (b *Broker[T]) GetSubscriberCount() int { + b.mu.RLock() + defer b.mu.RUnlock() + return b.subCount +} + func (b *Broker[T]) Publish(t EventType, payload T) { - b.mu.Lock() - defer b.mu.Unlock() + b.mu.RLock() + select { + case <-b.done: + b.mu.RUnlock() + return + default: + } + subscribers := make([]chan Event[T], 0, len(b.subs)) for sub := range b.subs { + subscribers = append(subscribers, sub) + } + b.mu.RUnlock() + + event := Event[T]{Type: t, Payload: payload} + + for _, sub := range subscribers { select { - case sub <- Event[T]{Type: t, Payload: payload}: - case <-b.done: - return + case sub <- event: + default: } } } diff --git a/internal/tui/components/chat/list.go b/internal/tui/components/chat/list.go index b09cc4495..03a50541e 100644 --- a/internal/tui/components/chat/list.go +++ b/internal/tui/components/chat/list.go @@ -370,6 +370,7 @@ func (m *messagesCmp) SetSize(width, height int) tea.Cmd { delete(m.cachedContent, msg.ID) } m.uiMessages = make([]uiMessage, 0) + m.renderView() return nil } diff --git a/internal/tui/components/core/status.go b/internal/tui/components/core/status.go index 5a2114e83..8bf3e5166 100644 --- a/internal/tui/components/core/status.go +++ b/internal/tui/components/core/status.go @@ -18,6 +18,11 @@ import ( "github.com/kujtimiihoxha/opencode/internal/tui/util" ) +type StatusCmp interface { + tea.Model + SetHelpMsg(string) +} + type statusCmp struct { info util.InfoMsg width int @@ -146,7 +151,7 @@ func (m *statusCmp) projectDiagnostics() string { break } } - + // If any server is initializing, show that status if initializing { return lipgloss.NewStyle(). @@ -154,7 +159,7 @@ func (m *statusCmp) projectDiagnostics() string { Foreground(styles.Peach). Render(fmt.Sprintf("%s Initializing LSP...", styles.SpinnerIcon)) } - + errorDiagnostics := []protocol.Diagnostic{} warnDiagnostics := []protocol.Diagnostic{} hintDiagnostics := []protocol.Diagnostic{} @@ -235,7 +240,11 @@ func (m statusCmp) model() string { return styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render(model.Name) } -func NewStatusCmp(lspClients map[string]*lsp.Client) tea.Model { +func (m statusCmp) SetHelpMsg(s string) { + helpWidget = styles.Padded.Background(styles.Forground).Foreground(styles.BackgroundDarker).Bold(true).Render(s) +} + +func NewStatusCmp(lspClients map[string]*lsp.Client) StatusCmp { return &statusCmp{ messageTTL: 10 * time.Second, lspClients: lspClients, diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 2a9ed0d70..dec43f7c0 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -39,12 +39,18 @@ var keys = keyMap{ key.WithKeys("ctrl+_"), key.WithHelp("ctrl+?", "toggle help"), ), + SwitchSession: key.NewBinding( key.WithKeys("ctrl+a"), key.WithHelp("ctrl+a", "switch session"), ), } +var helpEsc = key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "toggle help"), +) + var returnKey = key.NewBinding( key.WithKeys("esc"), key.WithHelp("esc", "close"), @@ -61,7 +67,7 @@ type appModel struct { previousPage page.PageID pages map[page.PageID]tea.Model loadedPages map[page.PageID]bool - status tea.Model + status core.StatusCmp app *app.App showPermissions bool @@ -75,6 +81,8 @@ type appModel struct { showSessionDialog bool sessionDialog dialog.SessionDialog + + editingMode bool } func (a appModel) Init() tea.Cmd { @@ -101,7 +109,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { msg.Height -= 1 // Make space for the status bar a.width, a.height = msg.Width, msg.Height - a.status, _ = a.status.Update(msg) + s, _ := a.status.Update(msg) + a.status = s.(core.StatusCmp) a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg) cmds = append(cmds, cmd) @@ -118,45 +127,56 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, sessionCmd) return a, tea.Batch(cmds...) - + case chat.EditorFocusMsg: + a.editingMode = bool(msg) // Status case util.InfoMsg: - a.status, cmd = a.status.Update(msg) + s, cmd := a.status.Update(msg) + a.status = s.(core.StatusCmp) cmds = append(cmds, cmd) return a, tea.Batch(cmds...) case pubsub.Event[logging.LogMessage]: if msg.Payload.Persist { switch msg.Payload.Level { case "error": - a.status, cmd = a.status.Update(util.InfoMsg{ + s, cmd := a.status.Update(util.InfoMsg{ Type: util.InfoTypeError, Msg: msg.Payload.Message, TTL: msg.Payload.PersistTime, }) + a.status = s.(core.StatusCmp) + cmds = append(cmds, cmd) case "info": - a.status, cmd = a.status.Update(util.InfoMsg{ + s, cmd := a.status.Update(util.InfoMsg{ Type: util.InfoTypeInfo, Msg: msg.Payload.Message, TTL: msg.Payload.PersistTime, }) + a.status = s.(core.StatusCmp) + cmds = append(cmds, cmd) + case "warn": - a.status, cmd = a.status.Update(util.InfoMsg{ + s, cmd := a.status.Update(util.InfoMsg{ Type: util.InfoTypeWarn, Msg: msg.Payload.Message, TTL: msg.Payload.PersistTime, }) + a.status = s.(core.StatusCmp) + cmds = append(cmds, cmd) default: - a.status, cmd = a.status.Update(util.InfoMsg{ + s, cmd := a.status.Update(util.InfoMsg{ Type: util.InfoTypeInfo, Msg: msg.Payload.Message, TTL: msg.Payload.PersistTime, }) + a.status = s.(core.StatusCmp) + cmds = append(cmds, cmd) } - cmds = append(cmds, cmd) } case util.ClearStatusMsg: - a.status, _ = a.status.Update(msg) + s, _ := a.status.Update(msg) + a.status = s.(core.StatusCmp) // Permission case pubsub.Event[permission.PermissionRequest]: @@ -243,7 +263,16 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } a.showHelp = !a.showHelp return a, nil + case key.Matches(msg, helpEsc): + if !a.editingMode { + if a.showQuit { + return a, nil + } + a.showHelp = !a.showHelp + return a, nil + } } + } if a.showQuit { @@ -275,7 +304,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - a.status, _ = a.status.Update(msg) + s, _ := a.status.Update(msg) + a.status = s.(core.StatusCmp) a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg) cmds = append(cmds, cmd) return a, tea.Batch(cmds...) @@ -326,6 +356,12 @@ func (a appModel) View() string { ) } + if a.editingMode { + a.status.SetHelpMsg("ctrl+? help") + } else { + a.status.SetHelpMsg("? help") + } + if a.showHelp { bindings := layout.KeyMapToSlice(keys) if p, ok := a.pages[a.currentPage].(layout.Bindings); ok { @@ -337,7 +373,9 @@ func (a appModel) View() string { if a.currentPage == page.LogsPage { bindings = append(bindings, logsKeyReturnKey) } - + if !a.editingMode { + bindings = append(bindings, helpEsc) + } a.help.SetBindings(bindings) overlay := a.help.View() @@ -398,6 +436,7 @@ func New(app *app.App) tea.Model { sessionDialog: dialog.NewSessionDialogCmp(), permissions: dialog.NewPermissionDialogCmp(), app: app, + editingMode: true, pages: map[page.PageID]tea.Model{ page.ChatPage: page.NewChatPage(app), page.LogsPage: page.NewLogsPage(), -- cgit v1.2.3