on main help sign in
fix the memory bug
e7bb99bKujtim Hoxha
README.md+5−2
@@ -351,9 +351,12 @@ go build -o opencode
351
351
352## Acknowledgments
352## Acknowledgments
353
353
354−OpenCode builds upon the work of several open source projects and developers:
354+OpenCode gratefully acknowledges the contributions and support from these key individuals:
355
355
356−- [@isaacphi](https://github.com/isaacphi) - LSP client implementation
356+- [@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
357+- [@adamdottv](https://github.com/adamdottv) - For the design direction and UI/UX architecture
358+
359+Special thanks to the broader open source community whose tools and libraries have made this project possible.
357
360
358## License
361## License
359
362
cmd/root.go+11−13
@@ -79,7 +79,7 @@ var rootCmd = &cobra.Command{
79 initMCPTools(ctx, app)
79 initMCPTools(ctx, app)
80
80
81 // Setup the subscriptions, this will send services events to the TUI
81 // Setup the subscriptions, this will send services events to the TUI
82− ch, cancelSubs := setupSubscriptions(app)
82+ ch, cancelSubs := setupSubscriptions(app, ctx)
83
83
84 // Create a context for the TUI message handler
84 // Create a context for the TUI message handler
85 tuiCtx, tuiCancel := context.WithCancel(ctx)
85 tuiCtx, tuiCancel := context.WithCancel(ctx)
@@ -174,21 +174,21 @@ func setupSubscriber[T any](
174 defer wg.Done()
174 defer wg.Done()
175 defer logging.RecoverPanic(fmt.Sprintf("subscription-%s", name), nil)
175 defer logging.RecoverPanic(fmt.Sprintf("subscription-%s", name), nil)
176
176
177+ subCh := subscriber(ctx)
178+
177 for {
179 for {
178 select {
180 select {
179− case event, ok := <-subscriber(ctx):
181+ case event, ok := <-subCh:
180 if !ok {
182 if !ok {
181 logging.Info("%s subscription channel closed", name)
183 logging.Info("%s subscription channel closed", name)
182 return
184 return
183 }
185 }
184
186
185− // Convert generic event to tea.Msg if needed
186 var msg tea.Msg = event
187 var msg tea.Msg = event
187
188
188− // Non-blocking send with timeout to prevent deadlocks
189 select {
189 select {
190 case outputCh <- msg:
190 case outputCh <- msg:
191− case <-time.After(500 * time.Millisecond):
191+ case <-time.After(2 * time.Second):
192 logging.Warn("%s message dropped due to slow consumer", name)
192 logging.Warn("%s message dropped due to slow consumer", name)
193 case <-ctx.Done():
193 case <-ctx.Done():
194 logging.Info("%s subscription cancelled", name)
194 logging.Info("%s subscription cancelled", name)
@@ -202,23 +202,21 @@ func setupSubscriber[T any](
202 }()
202 }()
203}
203}
204
204
205−func setupSubscriptions(app *app.App) (chan tea.Msg, func()) {
205+func setupSubscriptions(app *app.App, parentCtx context.Context) (chan tea.Msg, func()) {
206 ch := make(chan tea.Msg, 100)
206 ch := make(chan tea.Msg, 100)
207− // Add a buffer to prevent blocking
207+
208 wg := sync.WaitGroup{}
208 wg := sync.WaitGroup{}
209− ctx, cancel := context.WithCancel(context.Background())
209+ ctx, cancel := context.WithCancel(parentCtx) // Inherit from parent context
210− // Setup each subscription using the helper
210+
211 setupSubscriber(ctx, &wg, "logging", logging.Subscribe, ch)
211 setupSubscriber(ctx, &wg, "logging", logging.Subscribe, ch)
212 setupSubscriber(ctx, &wg, "sessions", app.Sessions.Subscribe, ch)
212 setupSubscriber(ctx, &wg, "sessions", app.Sessions.Subscribe, ch)
213 setupSubscriber(ctx, &wg, "messages", app.Messages.Subscribe, ch)
213 setupSubscriber(ctx, &wg, "messages", app.Messages.Subscribe, ch)
214 setupSubscriber(ctx, &wg, "permissions", app.Permissions.Subscribe, ch)
214 setupSubscriber(ctx, &wg, "permissions", app.Permissions.Subscribe, ch)
215
215
216− // Return channel and a cleanup function
217 cleanupFunc := func() {
216 cleanupFunc := func() {
218 logging.Info("Cancelling all subscriptions")
217 logging.Info("Cancelling all subscriptions")
219 cancel() // Signal all goroutines to stop
218 cancel() // Signal all goroutines to stop
220
219
221− // Wait with a timeout for all goroutines to complete
222 waitCh := make(chan struct{})
220 waitCh := make(chan struct{})
223 go func() {
221 go func() {
224 defer logging.RecoverPanic("subscription-cleanup", nil)
222 defer logging.RecoverPanic("subscription-cleanup", nil)
@@ -229,11 +227,11 @@ func setupSubscriptions(app *app.App) (chan tea.Msg, func()) {
229 select {
227 select {
230 case <-waitCh:
228 case <-waitCh:
231 logging.Info("All subscription goroutines completed successfully")
229 logging.Info("All subscription goroutines completed successfully")
230+ close(ch) // Only close after all writers are confirmed done
232 case <-time.After(5 * time.Second):
231 case <-time.After(5 * time.Second):
233 logging.Warn("Timed out waiting for some subscription goroutines to complete")
232 logging.Warn("Timed out waiting for some subscription goroutines to complete")
233+ close(ch)
234 }
234 }
235−
236− close(ch) // Safe to close after all writers are done or timed out
237 }
235 }
238 return ch, cleanupFunc
236 return ch, cleanupFunc
239}
237}
internal/pubsub/broker.go+47−25
@@ -5,47 +5,53 @@ import (
5 "sync"
5 "sync"
6)
6)
7
7
8−const bufferSize = 1024
8+const bufferSize = 64
9
9
10−// Broker allows clients to publish events and subscribe to events
11type Broker[T any] struct {
10type Broker[T any] struct {
12− subs map[chan Event[T]]struct{} // subscriptions
11+ subs map[chan Event[T]]struct{}
13− mu sync.Mutex // sync access to map
12+ mu sync.RWMutex
14− done chan struct{} // close when broker is shutting down
13+ done chan struct{}
14+ subCount int
15+ maxEvents int
15}
16}
16
17
17−// NewBroker constructs a pub/sub broker.
18func NewBroker[T any]() *Broker[T] {
18func NewBroker[T any]() *Broker[T] {
19+ return NewBrokerWithOptions[T](bufferSize, 1000)
20+}
21+
22+func NewBrokerWithOptions[T any](channelBufferSize, maxEvents int) *Broker[T] {
19 b := &Broker[T]{
23 b := &Broker[T]{
20− subs: make(map[chan Event[T]]struct{}),
24+ subs: make(map[chan Event[T]]struct{}),
21− done: make(chan struct{}),
25+ done: make(chan struct{}),
26+ subCount: 0,
27+ maxEvents: maxEvents,
22 }
28 }
23 return b
29 return b
24}
30}
25
31
26−// Shutdown the broker, terminating any subscriptions.
27func (b *Broker[T]) Shutdown() {
32func (b *Broker[T]) Shutdown() {
33+ select {
34+ case <-b.done: // Already closed
35+ return
36+ default:
28− close(b.done)
37+ close(b.done)
38+ }
29
39
30 b.mu.Lock()
40 b.mu.Lock()
31 defer b.mu.Unlock()
41 defer b.mu.Unlock()
32
42
33− // Remove each subscriber entry, so Publish() cannot send any further
34− // messages, and close each subscriber's channel, so the subscriber cannot
35− // consume any more messages.
36 for ch := range b.subs {
43 for ch := range b.subs {
37 delete(b.subs, ch)
44 delete(b.subs, ch)
38 close(ch)
45 close(ch)
39 }
46 }
47+
48+ b.subCount = 0
40}
49}
41
50
42−// Subscribe subscribes the caller to a stream of events. The returned channel
43−// is closed when the broker is shutdown.
44func (b *Broker[T]) Subscribe(ctx context.Context) <-chan Event[T] {
51func (b *Broker[T]) Subscribe(ctx context.Context) <-chan Event[T] {
45 b.mu.Lock()
52 b.mu.Lock()
46 defer b.mu.Unlock()
53 defer b.mu.Unlock()
47
54
48− // Check if broker has shutdown and if so return closed channel
49 select {
55 select {
50 case <-b.done:
56 case <-b.done:
51 ch := make(chan Event[T])
57 ch := make(chan Event[T])
@@ -54,18 +60,16 @@ func (b *Broker[T]) Subscribe(ctx context.Context) <-chan Event[T] {
54 default:
60 default:
55 }
61 }
56
62
57− // Subscribe
58 sub := make(chan Event[T], bufferSize)
63 sub := make(chan Event[T], bufferSize)
59 b.subs[sub] = struct{}{}
64 b.subs[sub] = struct{}{}
65+ b.subCount++
60
66
61− // Unsubscribe when context is done.
62 go func() {
67 go func() {
63 <-ctx.Done()
68 <-ctx.Done()
64
69
65 b.mu.Lock()
70 b.mu.Lock()
66 defer b.mu.Unlock()
71 defer b.mu.Unlock()
67
72
68− // Check if broker has shutdown and if so do nothing
69 select {
73 select {
70 case <-b.done:
74 case <-b.done:
71 return
75 return
@@ -74,21 +78,39 @@ func (b *Broker[T]) Subscribe(ctx context.Context) <-chan Event[T] {
74
78
75 delete(b.subs, sub)
79 delete(b.subs, sub)
76 close(sub)
80 close(sub)
81+ b.subCount--
77 }()
82 }()
78
83
79 return sub
84 return sub
80}
85}
81
86
82−// Publish an event to subscribers.
87+func (b *Broker[T]) GetSubscriberCount() int {
88+ b.mu.RLock()
89+ defer b.mu.RUnlock()
90+ return b.subCount
91+}
92+
83func (b *Broker[T]) Publish(t EventType, payload T) {
93func (b *Broker[T]) Publish(t EventType, payload T) {
84− b.mu.Lock()
94+ b.mu.RLock()
85− defer b.mu.Unlock()
95+ select {
96+ case <-b.done:
97+ b.mu.RUnlock()
98+ return
99+ default:
100+ }
86
101
102+ subscribers := make([]chan Event[T], 0, len(b.subs))
87 for sub := range b.subs {
103 for sub := range b.subs {
104+ subscribers = append(subscribers, sub)
105+ }
106+ b.mu.RUnlock()
107+
108+ event := Event[T]{Type: t, Payload: payload}
109+
110+ for _, sub := range subscribers {
88 select {
111 select {
89− case sub <- Event[T]{Type: t, Payload: payload}:
112+ case sub <- event:
90− case <-b.done:
113+ default:
91− return
92 }
114 }
93 }
115 }
94}
116}
internal/tui/components/chat/list.go+1
@@ -370,6 +370,7 @@ func (m *messagesCmp) SetSize(width, height int) tea.Cmd {
370 delete(m.cachedContent, msg.ID)
370 delete(m.cachedContent, msg.ID)
371 }
371 }
372 m.uiMessages = make([]uiMessage, 0)
372 m.uiMessages = make([]uiMessage, 0)
373+ m.renderView()
373 return nil
374 return nil
374}
375}
375
376
internal/tui/components/core/status.go+12−3
@@ -18,6 +18,11 @@ import (
18 "github.com/kujtimiihoxha/opencode/internal/tui/util"
18 "github.com/kujtimiihoxha/opencode/internal/tui/util"
19)
19)
20
20
21+type StatusCmp interface {
22+ tea.Model
23+ SetHelpMsg(string)
24+}
25+
21type statusCmp struct {
26type statusCmp struct {
22 info util.InfoMsg
27 info util.InfoMsg
23 width int
28 width int
@@ -146,7 +151,7 @@ func (m *statusCmp) projectDiagnostics() string {
146 break
151 break
147 }
152 }
148 }
153 }
149−
154+
150 // If any server is initializing, show that status
155 // If any server is initializing, show that status
151 if initializing {
156 if initializing {
152 return lipgloss.NewStyle().
157 return lipgloss.NewStyle().
@@ -154,7 +159,7 @@ func (m *statusCmp) projectDiagnostics() string {
154 Foreground(styles.Peach).
159 Foreground(styles.Peach).
155 Render(fmt.Sprintf("%s Initializing LSP...", styles.SpinnerIcon))
160 Render(fmt.Sprintf("%s Initializing LSP...", styles.SpinnerIcon))
156 }
161 }
157−
162+
158 errorDiagnostics := []protocol.Diagnostic{}
163 errorDiagnostics := []protocol.Diagnostic{}
159 warnDiagnostics := []protocol.Diagnostic{}
164 warnDiagnostics := []protocol.Diagnostic{}
160 hintDiagnostics := []protocol.Diagnostic{}
165 hintDiagnostics := []protocol.Diagnostic{}
@@ -235,7 +240,11 @@ func (m statusCmp) model() string {
235 return styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render(model.Name)
240 return styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render(model.Name)
236}
241}
237
242
243+func (m statusCmp) SetHelpMsg(s string) {
244+ helpWidget = styles.Padded.Background(styles.Forground).Foreground(styles.BackgroundDarker).Bold(true).Render(s)
245+}
246+
238−func NewStatusCmp(lspClients map[string]*lsp.Client) tea.Model {
247+func NewStatusCmp(lspClients map[string]*lsp.Client) StatusCmp {
239 return &statusCmp{
248 return &statusCmp{
240 messageTTL: 10 * time.Second,
249 messageTTL: 10 * time.Second,
241 lspClients: lspClients,
250 lspClients: lspClients,
internal/tui/tui.go+51−12
@@ -39,12 +39,18 @@ var keys = keyMap{
39 key.WithKeys("ctrl+_"),
39 key.WithKeys("ctrl+_"),
40 key.WithHelp("ctrl+?", "toggle help"),
40 key.WithHelp("ctrl+?", "toggle help"),
41 ),
41 ),
42+
42 SwitchSession: key.NewBinding(
43 SwitchSession: key.NewBinding(
43 key.WithKeys("ctrl+a"),
44 key.WithKeys("ctrl+a"),
44 key.WithHelp("ctrl+a", "switch session"),
45 key.WithHelp("ctrl+a", "switch session"),
45 ),
46 ),
46}
47}
47
48
49+var helpEsc = key.NewBinding(
50+ key.WithKeys("?"),
51+ key.WithHelp("?", "toggle help"),
52+)
53+
48var returnKey = key.NewBinding(
54var returnKey = key.NewBinding(
49 key.WithKeys("esc"),
55 key.WithKeys("esc"),
50 key.WithHelp("esc", "close"),
56 key.WithHelp("esc", "close"),
@@ -61,7 +67,7 @@ type appModel struct {
61 previousPage page.PageID
67 previousPage page.PageID
62 pages map[page.PageID]tea.Model
68 pages map[page.PageID]tea.Model
63 loadedPages map[page.PageID]bool
69 loadedPages map[page.PageID]bool
64− status tea.Model
70+ status core.StatusCmp
65 app *app.App
71 app *app.App
66
72
67 showPermissions bool
73 showPermissions bool
@@ -75,6 +81,8 @@ type appModel struct {
75
81
76 showSessionDialog bool
82 showSessionDialog bool
77 sessionDialog dialog.SessionDialog
83 sessionDialog dialog.SessionDialog
84+
85+ editingMode bool
78}
86}
79
87
80func (a appModel) Init() tea.Cmd {
88func (a appModel) Init() tea.Cmd {
@@ -101,7 +109,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
101 msg.Height -= 1 // Make space for the status bar
109 msg.Height -= 1 // Make space for the status bar
102 a.width, a.height = msg.Width, msg.Height
110 a.width, a.height = msg.Width, msg.Height
103
111
104− a.status, _ = a.status.Update(msg)
112+ s, _ := a.status.Update(msg)
113+ a.status = s.(core.StatusCmp)
105 a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
114 a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
106 cmds = append(cmds, cmd)
115 cmds = append(cmds, cmd)
107
116
@@ -118,45 +127,56 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
118 cmds = append(cmds, sessionCmd)
127 cmds = append(cmds, sessionCmd)
119
128
120 return a, tea.Batch(cmds...)
129 return a, tea.Batch(cmds...)
121−
130+ case chat.EditorFocusMsg:
131+ a.editingMode = bool(msg)
122 // Status
132 // Status
123 case util.InfoMsg:
133 case util.InfoMsg:
124− a.status, cmd = a.status.Update(msg)
134+ s, cmd := a.status.Update(msg)
135+ a.status = s.(core.StatusCmp)
125 cmds = append(cmds, cmd)
136 cmds = append(cmds, cmd)
126 return a, tea.Batch(cmds...)
137 return a, tea.Batch(cmds...)
127 case pubsub.Event[logging.LogMessage]:
138 case pubsub.Event[logging.LogMessage]:
128 if msg.Payload.Persist {
139 if msg.Payload.Persist {
129 switch msg.Payload.Level {
140 switch msg.Payload.Level {
130 case "error":
141 case "error":
131− a.status, cmd = a.status.Update(util.InfoMsg{
142+ s, cmd := a.status.Update(util.InfoMsg{
132 Type: util.InfoTypeError,
143 Type: util.InfoTypeError,
133 Msg: msg.Payload.Message,
144 Msg: msg.Payload.Message,
134 TTL: msg.Payload.PersistTime,
145 TTL: msg.Payload.PersistTime,
135 })
146 })
147+ a.status = s.(core.StatusCmp)
148+ cmds = append(cmds, cmd)
136 case "info":
149 case "info":
137− a.status, cmd = a.status.Update(util.InfoMsg{
150+ s, cmd := a.status.Update(util.InfoMsg{
138 Type: util.InfoTypeInfo,
151 Type: util.InfoTypeInfo,
139 Msg: msg.Payload.Message,
152 Msg: msg.Payload.Message,
140 TTL: msg.Payload.PersistTime,
153 TTL: msg.Payload.PersistTime,
141 })
154 })
155+ a.status = s.(core.StatusCmp)
156+ cmds = append(cmds, cmd)
157+
142 case "warn":
158 case "warn":
143− a.status, cmd = a.status.Update(util.InfoMsg{
159+ s, cmd := a.status.Update(util.InfoMsg{
144 Type: util.InfoTypeWarn,
160 Type: util.InfoTypeWarn,
145 Msg: msg.Payload.Message,
161 Msg: msg.Payload.Message,
146 TTL: msg.Payload.PersistTime,
162 TTL: msg.Payload.PersistTime,
147 })
163 })
148
164
165+ a.status = s.(core.StatusCmp)
166+ cmds = append(cmds, cmd)
149 default:
167 default:
150− a.status, cmd = a.status.Update(util.InfoMsg{
168+ s, cmd := a.status.Update(util.InfoMsg{
151 Type: util.InfoTypeInfo,
169 Type: util.InfoTypeInfo,
152 Msg: msg.Payload.Message,
170 Msg: msg.Payload.Message,
153 TTL: msg.Payload.PersistTime,
171 TTL: msg.Payload.PersistTime,
154 })
172 })
173+ a.status = s.(core.StatusCmp)
174+ cmds = append(cmds, cmd)
155 }
175 }
156− cmds = append(cmds, cmd)
157 }
176 }
158 case util.ClearStatusMsg:
177 case util.ClearStatusMsg:
159− a.status, _ = a.status.Update(msg)
178+ s, _ := a.status.Update(msg)
179+ a.status = s.(core.StatusCmp)
160
180
161 // Permission
181 // Permission
162 case pubsub.Event[permission.PermissionRequest]:
182 case pubsub.Event[permission.PermissionRequest]:
@@ -243,7 +263,16 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
243 }
263 }
244 a.showHelp = !a.showHelp
264 a.showHelp = !a.showHelp
245 return a, nil
265 return a, nil
266+ case key.Matches(msg, helpEsc):
267+ if !a.editingMode {
268+ if a.showQuit {
269+ return a, nil
270+ }
271+ a.showHelp = !a.showHelp
272+ return a, nil
273+ }
246 }
274 }
275+
247 }
276 }
248
277
249 if a.showQuit {
278 if a.showQuit {
@@ -275,7 +304,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
275 }
304 }
276 }
305 }
277
306
278− a.status, _ = a.status.Update(msg)
307+ s, _ := a.status.Update(msg)
308+ a.status = s.(core.StatusCmp)
279 a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
309 a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
280 cmds = append(cmds, cmd)
310 cmds = append(cmds, cmd)
281 return a, tea.Batch(cmds...)
311 return a, tea.Batch(cmds...)
@@ -326,6 +356,12 @@ func (a appModel) View() string {
326 )
356 )
327 }
357 }
328
358
359+ if a.editingMode {
360+ a.status.SetHelpMsg("ctrl+? help")
361+ } else {
362+ a.status.SetHelpMsg("? help")
363+ }
364+
329 if a.showHelp {
365 if a.showHelp {
330 bindings := layout.KeyMapToSlice(keys)
366 bindings := layout.KeyMapToSlice(keys)
331 if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
367 if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
@@ -337,7 +373,9 @@ func (a appModel) View() string {
337 if a.currentPage == page.LogsPage {
373 if a.currentPage == page.LogsPage {
338 bindings = append(bindings, logsKeyReturnKey)
374 bindings = append(bindings, logsKeyReturnKey)
339 }
375 }
340−
376+ if !a.editingMode {
377+ bindings = append(bindings, helpEsc)
378+ }
341 a.help.SetBindings(bindings)
379 a.help.SetBindings(bindings)
342
380
343 overlay := a.help.View()
381 overlay := a.help.View()
@@ -398,6 +436,7 @@ func New(app *app.App) tea.Model {
398 sessionDialog: dialog.NewSessionDialogCmp(),
436 sessionDialog: dialog.NewSessionDialogCmp(),
399 permissions: dialog.NewPermissionDialogCmp(),
437 permissions: dialog.NewPermissionDialogCmp(),
400 app: app,
438 app: app,
439+ editingMode: true,
401 pages: map[page.PageID]tea.Model{
440 pages: map[page.PageID]tea.Model{
402 page.ChatPage: page.NewChatPage(app),
441 page.ChatPage: page.NewChatPage(app),
403 page.LogsPage: page.NewLogsPage(),
442 page.LogsPage: page.NewLogsPage(),