diff --git a/README.md b/README.md index ef55b69294670f2e6b4fc410204224f3a785dee2..075114fc335e9cc9ccfdf035478bce73a125f590 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 f506e99404f2bdc2d0331f592ffe2ab69b560ef5..54280ecaaab7a8dbab3cd5555c53a526ae177134 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 d73accffba6ed7695cd845143ef7a611a8058682..0de1be063b05e522c951ee9fe25c9358cf44ef52 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 b09cc449507881b6f5030fe4f1eaabc286784219..03a50541e4da1a8f2bdd7a73a9e626f5e9c098da 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 5a2114e8363dbb424db41be18908bb50570a5c40..8bf3e516614ea92b1c57b7efc3a2f24980c9d1dc 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 2a9ed0d70d193f0fdb568cd372b8d31241f43b87..dec43f7c074a71d435c0f442bec40823e6b0fe2f 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(),