fix the memory bug

Kujtim Hoxha created

Change summary

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(-)

Detailed changes

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
 

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
 }

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:
 		}
 	}
 }

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
 }
 

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,

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(),