on main help sign in
wip dialogs
f154400Kujtim Hoxha
cspell.jsonnew file+1
@@ -0,0 +1 @@
1+{"flagWords":[],"version":"0.2","words":["opencode","charmbracelet","lipgloss","bubbletea"],"language":"en"}
go.mod+9−9
@@ -11,11 +11,11 @@ require (
11 github.com/aymanbagabas/go-udiff v0.2.0
11 github.com/aymanbagabas/go-udiff v0.2.0
12 github.com/bmatcuk/doublestar/v4 v4.8.1
12 github.com/bmatcuk/doublestar/v4 v4.8.1
13 github.com/catppuccin/go v0.3.0
13 github.com/catppuccin/go v0.3.0
14− github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1
14+ github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318
15− github.com/charmbracelet/bubbletea/v2 v2.0.0-beta1
15+ github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250526132317-434f93986a5c
16− github.com/charmbracelet/glamour/v2 v2.0.0-20250513163904-eeeced3bb3c6
16+ github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
17− github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250513162854-28902d027c40
17+ github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c
18− github.com/charmbracelet/x/ansi v0.9.2
18+ github.com/charmbracelet/x/ansi v0.9.3-0.20250516160309-24eee56f89fa
19 github.com/fsnotify/fsnotify v1.8.0
19 github.com/fsnotify/fsnotify v1.8.0
20 github.com/go-logfmt/logfmt v0.6.0
20 github.com/go-logfmt/logfmt v0.6.0
21 github.com/google/uuid v1.6.0
21 github.com/google/uuid v1.6.0
@@ -57,12 +57,12 @@ require (
57 github.com/aws/smithy-go v1.20.3 // indirect
57 github.com/aws/smithy-go v1.20.3 // indirect
58 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
58 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
59 github.com/aymerick/douceur v0.2.0 // indirect
59 github.com/aymerick/douceur v0.2.0 // indirect
60− github.com/charmbracelet/colorprofile v0.3.0 // indirect
60+ github.com/charmbracelet/colorprofile v0.3.1 // indirect
61− github.com/charmbracelet/x/cellbuf v0.0.14-0.20250326144200-0875329e71da // indirect
61+ github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa // indirect
62 github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
62 github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
63− github.com/charmbracelet/x/input v0.3.4 // indirect
63+ github.com/charmbracelet/x/input v0.3.5-0.20250509021451-13796e822d86 // indirect
64 github.com/charmbracelet/x/term v0.2.1 // indirect
64 github.com/charmbracelet/x/term v0.2.1 // indirect
65− github.com/charmbracelet/x/windows v0.2.0 // indirect
65+ github.com/charmbracelet/x/windows v0.2.1 // indirect
66 github.com/davecgh/go-spew v1.1.1 // indirect
66 github.com/davecgh/go-spew v1.1.1 // indirect
67 github.com/disintegration/imaging v1.6.2
67 github.com/disintegration/imaging v1.6.2
68 github.com/dlclark/regexp2 v1.11.4 // indirect
68 github.com/dlclark/regexp2 v1.11.4 // indirect
go.sum+18−18
@@ -68,30 +68,30 @@ github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR
68github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
68github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
69github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
69github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
70github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
70github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
71−github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 h1:swACzss0FjnyPz1enfX56GKkLiuKg5FlyVmOLIlU2kE=
71+github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318 h1:f8Q0ybZGxT+St1JfPM7yoz/XFpbmtodcIehaom/9XT8=
72−github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
72+github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
73−github.com/charmbracelet/bubbletea/v2 v2.0.0-beta1 h1:yaxFt97mvofGY7bYZn8U/aSVoamXGE3O4AEvWhshUDI=
73+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250526132317-434f93986a5c h1:EoW1x1K2EDKYw1D7raqZqWKnwk21IZVpYqLHQVhz1ZU=
74−github.com/charmbracelet/bubbletea/v2 v2.0.0-beta1/go.mod h1:qbcZLI5z8R49v9xBdU5V5Dh5D2uccx8wSwBqxQyErqc=
74+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250526132317-434f93986a5c/go.mod h1:sXuGtrlVJo43r1fVGBM06E7PPb16oBl8rDRr6YgQOck=
75−github.com/charmbracelet/colorprofile v0.3.0 h1:KtLh9uuu1RCt+Hml4s6Hz+kB1PfV3wi++1h5ia65yKQ=
75+github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
76−github.com/charmbracelet/colorprofile v0.3.0/go.mod h1:oHJ340RS2nmG1zRGPmhJKJ/jf4FPNNk0P39/wBPA1G0=
76+github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
77−github.com/charmbracelet/glamour/v2 v2.0.0-20250513163904-eeeced3bb3c6 h1:AKhOV8dSRU3KpqMgpGME9JU7ouumB2S6hMmD6PRJeTc=
77+github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe h1:i6ce4CcAlPpTj2ER69m1DBeLZ3RRcHnKExuwhKa3GfY=
78−github.com/charmbracelet/glamour/v2 v2.0.0-20250513163904-eeeced3bb3c6/go.mod h1:7xBAUTCSADx9mHG0uBf4NDoVpYxMzIQ2j/NMLGdFsFM=
78+github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe/go.mod h1:p3Q+aN4eQKeM5jhrmXPMgPrlKbmc59rWSnMsSA3udhk=
79−github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250513162854-28902d027c40 h1:SxOUomYAVo5zh+6WCH1bGshlAnSKP0ZeovI0FHAl9kg=
79+github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c h1:177KMz8zHRlEZJsWzafbKYh6OdjgvTspoH+UjaxgIXY=
80−github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250513162854-28902d027c40/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc=
80+github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c/go.mod h1:EJWvaCrhOhNGVZMvcjc0yVryl4qqpMs8tz0r9WyEkdQ=
81−github.com/charmbracelet/x/ansi v0.9.2 h1:92AGsQmNTRMzuzHEYfCdjQeUzTrgE1vfO5/7fEVoXdY=
81+github.com/charmbracelet/x/ansi v0.9.3-0.20250516160309-24eee56f89fa h1:JU05TLAB6nOEL46bxHDV/+e8umBX32ODsGbVkc7o7bk=
82−github.com/charmbracelet/x/ansi v0.9.2/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
82+github.com/charmbracelet/x/ansi v0.9.3-0.20250516160309-24eee56f89fa/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
83−github.com/charmbracelet/x/cellbuf v0.0.14-0.20250326144200-0875329e71da h1:8MGKD5WBtuzfXglq0CnyzVSwGojv57X+H46OL9OUyRA=
83+github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa h1:lphz0Z3rsiOtMYiz8axkT24i9yFiueDhJbzyNUADmME=
84−github.com/charmbracelet/x/cellbuf v0.0.14-0.20250326144200-0875329e71da/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
84+github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk=
85github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw=
85github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw=
86github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
86github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
87github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
87github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
88github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
88github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
89−github.com/charmbracelet/x/input v0.3.4 h1:Mujmnv/4DaitU0p+kIsrlfZl/UlmeLKw1wAP3e1fMN0=
89+github.com/charmbracelet/x/input v0.3.5-0.20250509021451-13796e822d86 h1:BxAEmOBIDajkgao3EsbBxKQCYvgYPGdT62WASLvtf4Y=
90−github.com/charmbracelet/x/input v0.3.4/go.mod h1:JI8RcvdZWQIhn09VzeK3hdp4lTz7+yhiEdpEQtZN+2c=
90+github.com/charmbracelet/x/input v0.3.5-0.20250509021451-13796e822d86/go.mod h1:62Rp/6EtTxoeJDSdtpA3tJp3y3ZRpsiekBSje+K8htA=
91github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
91github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
92github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
92github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
93−github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw=
93+github.com/charmbracelet/x/windows v0.2.1 h1:3x7vnbpQrjpuq/4L+I4gNsG5htYoCiA5oe9hLjAij5I=
94−github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s=
94+github.com/charmbracelet/x/windows v0.2.1/go.mod h1:ptZp16h40gDYqs5TSawSVW+yiLB13j4kSMA0lSCHL0M=
95github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
95github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
96github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
96github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
97github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
97github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
internal/tui/components/anim/anim.go+3−3
@@ -239,7 +239,7 @@ func (a *anim) updateChars(chars *[]cyclingChar) {
239}
239}
240
240
241// View renders the animation.
241// View renders the animation.
242−func (a anim) View() string {
242+func (a anim) View() tea.View {
243 t := theme.CurrentTheme()
243 t := theme.CurrentTheme()
244 var b strings.Builder
244 var b strings.Builder
245
245
@@ -259,10 +259,10 @@ func (a anim) View() string {
259 textStyle.Render(string(c.currentValue)),
259 textStyle.Render(string(c.currentValue)),
260 )
260 )
261 }
261 }
262− return b.String() + textStyle.Render(a.ellipsis.View())
262+ return tea.NewView(b.String() + textStyle.Render(a.ellipsis.View()))
263 }
263 }
264
264
265− return b.String()
265+ return tea.NewView(b.String())
266}
266}
267
267
268func makeGradientRamp(length int) []color.Color {
268func makeGradientRamp(length int) []color.Color {
internal/tui/components/chat/editor.go+21−6
@@ -26,6 +26,7 @@ import (
26type editorCmp struct {
26type editorCmp struct {
27 width int
27 width int
28 height int
28 height int
29+ x, y int
29 app *app.App
30 app *app.App
30 session session.Session
31 session session.Session
31 textarea textarea.Model
32 textarea textarea.Model
@@ -116,7 +117,7 @@ func (m *editorCmp) openEditor() tea.Cmd {
116}
117}
117
118
118func (m *editorCmp) Init() tea.Cmd {
119func (m *editorCmp) Init() tea.Cmd {
119− return textarea.Blink
120+ return nil
120}
121}
121
122
122func (m *editorCmp) send() tea.Cmd {
123func (m *editorCmp) send() tea.Cmd {
@@ -212,7 +213,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
212 return m, cmd
213 return m, cmd
213}
214}
214
215
215−func (m *editorCmp) View() string {
216+func (m *editorCmp) View() tea.View {
216 t := theme.CurrentTheme()
217 t := theme.CurrentTheme()
217
218
218 // Style the prompt with theme colors
219 // Style the prompt with theme colors
@@ -221,16 +222,23 @@ func (m *editorCmp) View() string {
221 Bold(true).
222 Bold(true).
222 Foreground(t.Primary())
223 Foreground(t.Primary())
223
224
225+ cursor := m.textarea.Cursor()
226+ cursor.X = m.textarea.Cursor().X + m.x + 2
227+ cursor.Y = m.textarea.Cursor().Y + m.y + 1
224 if len(m.attachments) == 0 {
228 if len(m.attachments) == 0 {
225− return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View())
229+ view := tea.NewView(lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View()))
230+ view.SetCursor(cursor)
231+ return view
226 }
232 }
227 m.textarea.SetHeight(m.height - 1)
233 m.textarea.SetHeight(m.height - 1)
228− return lipgloss.JoinVertical(lipgloss.Top,
234+ view := tea.NewView(lipgloss.JoinVertical(lipgloss.Top,
229 m.attachmentsContent(),
235 m.attachmentsContent(),
230 lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"),
236 lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"),
231 m.textarea.View(),
237 m.textarea.View(),
232 ),
238 ),
233− )
239+ ))
240+ view.SetCursor(cursor)
241+ return view
234}
242}
235
243
236func (m *editorCmp) SetSize(width, height int) tea.Cmd {
244func (m *editorCmp) SetSize(width, height int) tea.Cmd {
@@ -275,6 +283,12 @@ func (m *editorCmp) BindingKeys() []key.Binding {
275 return bindings
283 return bindings
276}
284}
277
285
286+func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
287+ m.x = x
288+ m.y = y
289+ return nil
290+}
291+
278func CreateTextArea(existing *textarea.Model) textarea.Model {
292func CreateTextArea(existing *textarea.Model) textarea.Model {
279 t := theme.CurrentTheme()
293 t := theme.CurrentTheme()
280 bgColor := t.Background()
294 bgColor := t.Background()
@@ -297,11 +311,12 @@ func CreateTextArea(existing *textarea.Model) textarea.Model {
297
311
298 s.Focused = f
312 s.Focused = f
299 s.Blurred = b
313 s.Blurred = b
300− ta.Styles = s
314+ ta.SetStyles(s)
301
315
302 ta.Prompt = " "
316 ta.Prompt = " "
303 ta.ShowLineNumbers = false
317 ta.ShowLineNumbers = false
304 ta.CharLimit = -1
318 ta.CharLimit = -1
319+ ta.SetVirtualCursor(false)
305
320
306 if existing != nil {
321 if existing != nil {
307 ta.SetValue(existing.Value())
322 ta.SetValue(existing.Value())
internal/tui/components/chat/list.go+3−3
@@ -104,11 +104,11 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
104}
104}
105
105
106// View renders the message list or an initial screen if empty.
106// View renders the message list or an initial screen if empty.
107−func (m *messageListCmp) View() string {
107+func (m *messageListCmp) View() tea.View {
108 if len(m.listCmp.Items()) == 0 {
108 if len(m.listCmp.Items()) == 0 {
109− return initialScreen()
109+ return tea.NewView(initialScreen())
110 }
110 }
111− return lipgloss.JoinVertical(lipgloss.Left, m.listCmp.View())
111+ return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, m.listCmp.View().String()))
112}
112}
113
113
114// handleChildSession handles messages from child sessions (agent tools).
114// handleChildSession handles messages from child sessions (agent tools).
internal/tui/components/chat/messages/messages.go+5−5
@@ -94,20 +94,20 @@ func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
94
94
95// View renders the message component based on its current state.
95// View renders the message component based on its current state.
96// Returns different views for spinning, user, and assistant messages.
96// Returns different views for spinning, user, and assistant messages.
97−func (m *messageCmp) View() string {
97+func (m *messageCmp) View() tea.View {
98 if m.spinning {
98 if m.spinning {
99− return m.style().PaddingLeft(1).Render(m.anim.View())
99+ return tea.NewView(m.style().PaddingLeft(1).Render(m.anim.View().String()))
100 }
100 }
101 if m.message.ID != "" {
101 if m.message.ID != "" {
102 // this is a user or assistant message
102 // this is a user or assistant message
103 switch m.message.Role {
103 switch m.message.Role {
104 case message.User:
104 case message.User:
105− return m.renderUserMessage()
105+ return tea.NewView(m.renderUserMessage())
106 default:
106 default:
107− return m.renderAssistantMessage()
107+ return tea.NewView(m.renderAssistantMessage())
108 }
108 }
109 }
109 }
110− return "Unknown Message"
110+ return tea.NewView(m.style().Render("No message content"))
111}
111}
112
112
113// GetMessage returns the underlying message data
113// GetMessage returns the underlying message data
internal/tui/components/chat/messages/renderer.go+1−1
@@ -507,7 +507,7 @@ func (tr agentRenderer) Render(v *toolCallCmp) string {
507 }
507 }
508 if v.result.ToolCallID == "" {
508 if v.result.ToolCallID == "" {
509 v.spinning = true
509 v.spinning = true
510− parts = append(parts, v.anim.View())
510+ parts = append(parts, v.anim.View().String())
511 } else {
511 } else {
512 v.spinning = false
512 v.spinning = false
513 }
513 }
internal/tui/components/chat/messages/tool.go+5−5
@@ -134,22 +134,22 @@ func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
134
134
135// View renders the tool call component based on its current state.
135// View renders the tool call component based on its current state.
136// Shows either a pending animation or the tool-specific rendered result.
136// Shows either a pending animation or the tool-specific rendered result.
137−func (m *toolCallCmp) View() string {
137+func (m *toolCallCmp) View() tea.View {
138 box := m.style()
138 box := m.style()
139
139
140 if !m.call.Finished && !m.cancelled {
140 if !m.call.Finished && !m.cancelled {
141 if m.isNested {
141 if m.isNested {
142− return box.Render(m.renderPending())
142+ return tea.NewView(box.Render(m.renderPending()))
143 }
143 }
144− return box.PaddingLeft(1).Render(m.renderPending())
144+ return tea.NewView(box.PaddingLeft(1).Render(m.renderPending()))
145 }
145 }
146
146
147 r := registry.lookup(m.call.Name)
147 r := registry.lookup(m.call.Name)
148
148
149 if m.isNested {
149 if m.isNested {
150− return box.Render(r.Render(m))
150+ return tea.NewView(box.Render(r.Render(m)))
151 }
151 }
152− return box.PaddingLeft(1).Render(r.Render(m))
152+ return tea.NewView(box.PaddingLeft(1).Render(r.Render(m)))
153}
153}
154
154
155// State management methods
155// State management methods
internal/tui/components/chat/sidebar.go+19−17
@@ -82,26 +82,28 @@ func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
82 return m, nil
82 return m, nil
83}
83}
84
84
85−func (m *sidebarCmp) View() string {
85+func (m *sidebarCmp) View() tea.View {
86 baseStyle := styles.BaseStyle()
86 baseStyle := styles.BaseStyle()
87
87
88+ return tea.NewView(
88− return baseStyle.
89+ baseStyle.
89− Width(m.width).
90+ Width(m.width).
90− PaddingLeft(4).
91+ PaddingLeft(4).
91− PaddingRight(2).
92+ PaddingRight(2).
92− Height(m.height - 1).
93+ Height(m.height - 1).
93− Render(
94+ Render(
94− lipgloss.JoinVertical(
95+ lipgloss.JoinVertical(
95− lipgloss.Top,
96+ lipgloss.Top,
96− header(),
97+ header(),
97− " ",
98+ " ",
98− m.sessionSection(),
99+ m.sessionSection(),
99− " ",
100+ " ",
100− lspsConfigured(),
101+ lspsConfigured(),
101− " ",
102+ " ",
102− m.modifiedFiles(),
103+ m.modifiedFiles(),
104+ ),
103 ),
105 ),
104− )
106+ )
105}
107}
106
108
107func (m *sidebarCmp) sessionSection() string {
109func (m *sidebarCmp) sessionSection() string {
internal/tui/components/core/list/list.go+9−4
@@ -269,14 +269,19 @@ func (m *model) scrollUp(amount int) {
269// View renders the list to a string for display.
269// View renders the list to a string for display.
270// Returns empty string if the list has no dimensions.
270// Returns empty string if the list has no dimensions.
271// Triggers re-rendering if needed before returning content.
271// Triggers re-rendering if needed before returning content.
272−func (m *model) View() string {
272+func (m *model) View() tea.View {
273 if m.viewState.height == 0 || m.viewState.width == 0 {
273 if m.viewState.height == 0 || m.viewState.width == 0 {
274− return ""
274+ return tea.NewView("") // No content to display
275 }
275 }
276 if m.renderState.needsRerender {
276 if m.renderState.needsRerender {
277 m.renderVisible()
277 m.renderVisible()
278 }
278 }
279+ return tea.NewView(
280+ lipgloss.NewStyle().
281+ Padding(m.padding...).
282+ Height(m.viewState.height).
279− return lipgloss.NewStyle().Padding(m.padding...).Height(m.viewState.height).Render(m.viewState.content)
283+ Render(m.viewState.content),
284+ )
280}
285}
281
286
282// Items returns a copy of all items in the list.
287// Items returns a copy of all items in the list.
@@ -642,7 +647,7 @@ func (m *model) rerenderItem(inx int) {
642
647
643// getItemLines converts an item to its rendered lines, including any gap spacing.
648// getItemLines converts an item to its rendered lines, including any gap spacing.
644func (m *model) getItemLines(item util.Model) []string {
649func (m *model) getItemLines(item util.Model) []string {
645− itemLines := strings.Split(item.View(), "\n")
650+ itemLines := strings.Split(item.View().String(), "\n")
646 if m.gapSize > 0 {
651 if m.gapSize > 0 {
647 gap := make([]string, m.gapSize)
652 gap := make([]string, m.gapSize)
648 itemLines = append(itemLines, gap...)
653 itemLines = append(itemLines, gap...)
internal/tui/components/core/status.go+38−2
@@ -9,6 +9,7 @@ import (
9 "github.com/charmbracelet/lipgloss/v2"
9 "github.com/charmbracelet/lipgloss/v2"
10 "github.com/opencode-ai/opencode/internal/config"
10 "github.com/opencode-ai/opencode/internal/config"
11 "github.com/opencode-ai/opencode/internal/llm/models"
11 "github.com/opencode-ai/opencode/internal/llm/models"
12+ "github.com/opencode-ai/opencode/internal/logging"
12 "github.com/opencode-ai/opencode/internal/lsp"
13 "github.com/opencode-ai/opencode/internal/lsp"
13 "github.com/opencode-ai/opencode/internal/lsp/protocol"
14 "github.com/opencode-ai/opencode/internal/lsp/protocol"
14 "github.com/opencode-ai/opencode/internal/pubsub"
15 "github.com/opencode-ai/opencode/internal/pubsub"
@@ -47,6 +48,8 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
47 case tea.WindowSizeMsg:
48 case tea.WindowSizeMsg:
48 m.width = msg.Width
49 m.width = msg.Width
49 return m, nil
50 return m, nil
51+
52+ // Handle sesson messages
50 case chat.SessionSelectedMsg:
53 case chat.SessionSelectedMsg:
51 m.session = msg
54 m.session = msg
52 case chat.SessionClearedMsg:
55 case chat.SessionClearedMsg:
@@ -57,6 +60,8 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
57 m.session = msg.Payload
60 m.session = msg.Payload
58 }
61 }
59 }
62 }
63+
64+ // Handle status info
60 case util.InfoMsg:
65 case util.InfoMsg:
61 m.info = msg
66 m.info = msg
62 ttl := msg.TTL
67 ttl := msg.TTL
@@ -66,6 +71,37 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
66 return m, m.clearMessageCmd(ttl)
71 return m, m.clearMessageCmd(ttl)
67 case util.ClearStatusMsg:
72 case util.ClearStatusMsg:
68 m.info = util.InfoMsg{}
73 m.info = util.InfoMsg{}
74+
75+ // Handle persistant logs
76+ case pubsub.Event[logging.LogMessage]:
77+ if msg.Payload.Persist {
78+ switch msg.Payload.Level {
79+ case "error":
80+ m.info = util.InfoMsg{
81+ Type: util.InfoTypeError,
82+ Msg: msg.Payload.Message,
83+ TTL: msg.Payload.PersistTime,
84+ }
85+ case "info":
86+ m.info = util.InfoMsg{
87+ Type: util.InfoTypeInfo,
88+ Msg: msg.Payload.Message,
89+ TTL: msg.Payload.PersistTime,
90+ }
91+ case "warn":
92+ m.info = util.InfoMsg{
93+ Type: util.InfoTypeWarn,
94+ Msg: msg.Payload.Message,
95+ TTL: msg.Payload.PersistTime,
96+ }
97+ default:
98+ m.info = util.InfoMsg{
99+ Type: util.InfoTypeInfo,
100+ Msg: msg.Payload.Message,
101+ TTL: msg.Payload.PersistTime,
102+ }
103+ }
104+ }
69 }
105 }
70 return m, nil
106 return m, nil
71}
107}
@@ -116,7 +152,7 @@ func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
116 return fmt.Sprintf("Context: %s, Cost: %s", formattedTokens, formattedCost)
152 return fmt.Sprintf("Context: %s, Cost: %s", formattedTokens, formattedCost)
117}
153}
118
154
119−func (m statusCmp) View() string {
155+func (m statusCmp) View() tea.View {
120 t := theme.CurrentTheme()
156 t := theme.CurrentTheme()
121 modelID := config.Get().Agents[config.AgentCoder].Model
157 modelID := config.Get().Agents[config.AgentCoder].Model
122 model := models.SupportedModels[modelID]
158 model := models.SupportedModels[modelID]
@@ -176,7 +212,7 @@ func (m statusCmp) View() string {
176
212
177 status += diagnostics
213 status += diagnostics
178 status += m.model()
214 status += m.model()
179− return status
215+ return tea.NewView(status)
180}
216}
181
217
182func (m *statusCmp) projectDiagnostics() string {
218func (m *statusCmp) projectDiagnostics() string {
internal/tui/components/dialog/arguments.go+7−6
@@ -73,12 +73,13 @@ func NewMultiArgumentsDialogCmp(commandID, content string, argNames []string) Mu
73 ti.Placeholder = fmt.Sprintf("Enter value for %s...", name)
73 ti.Placeholder = fmt.Sprintf("Enter value for %s...", name)
74 ti.SetWidth(40)
74 ti.SetWidth(40)
75 ti.Prompt = ""
75 ti.Prompt = ""
76+ styles := ti.Styles()
76− ti.Styles.Focused.Placeholder = ti.Styles.Focused.Placeholder.Background(t.Background())
77+ styles.Focused.Placeholder = styles.Focused.Placeholder.Background(t.Background())
77− ti.Styles.Blurred.Placeholder = ti.Styles.Blurred.Placeholder.Background(t.Background())
78+ styles.Blurred.Placeholder = styles.Blurred.Placeholder.Background(t.Background())
78− ti.Styles.Focused.Suggestion = ti.Styles.Focused.Suggestion.Background(t.Background()).Foreground(t.Primary())
79+ styles.Focused.Suggestion = styles.Focused.Suggestion.Background(t.Background()).Foreground(t.Primary())
79− ti.Styles.Blurred.Suggestion = ti.Styles.Blurred.Suggestion.Background(t.Background())
80+ styles.Blurred.Suggestion = styles.Blurred.Suggestion.Background(t.Background())
80− ti.Styles.Focused.Text = ti.Styles.Focused.Text.Background(t.Background()).Foreground(t.Primary())
81+ styles.Focused.Text = styles.Focused.Text.Background(t.Background()).Foreground(t.Primary())
81− ti.Styles.Blurred.Text = ti.Styles.Blurred.Text.Background(t.Background())
82+ styles.Blurred.Text = styles.Blurred.Text.Background(t.Background())
82
83
83 // Only focus the first input initially
84 // Only focus the first input initially
84 if i == 0 {
85 if i == 0 {
internal/tui/components/dialog/commands.go+10−8
@@ -114,7 +114,7 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
114 return c, tea.Batch(cmds...)
114 return c, tea.Batch(cmds...)
115}
115}
116
116
117−func (c *commandDialogCmp) View() string {
117+func (c *commandDialogCmp) View() tea.View {
118 t := theme.CurrentTheme()
118 t := theme.CurrentTheme()
119 baseStyle := styles.BaseStyle()
119 baseStyle := styles.BaseStyle()
120
120
@@ -146,16 +146,18 @@ func (c *commandDialogCmp) View() string {
146 lipgloss.Left,
146 lipgloss.Left,
147 title,
147 title,
148 baseStyle.Width(maxWidth).Render(""),
148 baseStyle.Width(maxWidth).Render(""),
149− baseStyle.Width(maxWidth).Render(c.listView.View()),
149+ baseStyle.Width(maxWidth).Render(c.listView.View().String()),
150 baseStyle.Width(maxWidth).Render(""),
150 baseStyle.Width(maxWidth).Render(""),
151 )
151 )
152
152
153+ return tea.NewView(
153− return baseStyle.Padding(1, 2).
154+ baseStyle.Padding(1, 2).
154− Border(lipgloss.RoundedBorder()).
155+ Border(lipgloss.RoundedBorder()).
155− BorderBackground(t.Background()).
156+ BorderBackground(t.Background()).
156− BorderForeground(t.TextMuted()).
157+ BorderForeground(t.TextMuted()).
157− Width(lipgloss.Width(content) + 4).
158+ Width(lipgloss.Width(content) + 4).
158− Render(content)
159+ Render(content),
160+ )
159}
161}
160
162
161func (c *commandDialogCmp) BindingKeys() []key.Binding {
163func (c *commandDialogCmp) BindingKeys() []key.Binding {
internal/tui/components/dialog/complete.go+12−10
@@ -202,7 +202,7 @@ func (c *completionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
202 return c, tea.Batch(cmds...)
202 return c, tea.Batch(cmds...)
203}
203}
204
204
205−func (c *completionDialogCmp) View() string {
205+func (c *completionDialogCmp) View() tea.View {
206 t := theme.CurrentTheme()
206 t := theme.CurrentTheme()
207 baseStyle := styles.BaseStyle()
207 baseStyle := styles.BaseStyle()
208
208
@@ -219,15 +219,17 @@ func (c *completionDialogCmp) View() string {
219
219
220 c.listView.SetMaxWidth(maxWidth)
220 c.listView.SetMaxWidth(maxWidth)
221
221
222+ return tea.NewView(
222− return baseStyle.Padding(0, 0).
223+ baseStyle.Padding(0, 0).
223− Border(lipgloss.NormalBorder()).
224+ Border(lipgloss.NormalBorder()).
224− BorderBottom(false).
225+ BorderBottom(false).
225− BorderRight(false).
226+ BorderRight(false).
226− BorderLeft(false).
227+ BorderLeft(false).
227− BorderBackground(t.Background()).
228+ BorderBackground(t.Background()).
228− BorderForeground(t.TextMuted()).
229+ BorderForeground(t.TextMuted()).
229− Width(c.width).
230+ Width(c.width).
230− Render(c.listView.View())
231+ Render(c.listView.View().String()),
232+ )
231}
233}
232
234
233func (c *completionDialogCmp) SetWidth(width int) {
235func (c *completionDialogCmp) SetWidth(width int) {
internal/tui/components/dialog/filepicker.go+4−2
@@ -258,7 +258,7 @@ func (f *filepickerCmp) addAttachmentToMessage() (tea.Model, tea.Cmd) {
258 return f, util.CmdHandler(AttachmentAddedMsg{attachment})
258 return f, util.CmdHandler(AttachmentAddedMsg{attachment})
259}
259}
260
260
261−func (f *filepickerCmp) View() string {
261+func (f *filepickerCmp) View() tea.View {
262 t := theme.CurrentTheme()
262 t := theme.CurrentTheme()
263 const maxVisibleDirs = 20
263 const maxVisibleDirs = 20
264 const maxWidth = 80
264 const maxWidth = 80
@@ -349,7 +349,9 @@ func (f *filepickerCmp) View() string {
349 BorderForeground(t.TextMuted()).
349 BorderForeground(t.TextMuted()).
350 Width(lipgloss.Width(content) + 4)
350 Width(lipgloss.Width(content) + 4)
351
351
352+ return tea.NewView(
352− return lipgloss.JoinHorizontal(lipgloss.Center, contentStyle.Render(content), viewportstyle)
353+ lipgloss.JoinHorizontal(lipgloss.Center, contentStyle.Render(content), viewportstyle),
354+ )
353}
355}
354
356
355type FilepickerCmp interface {
357type FilepickerCmp interface {
internal/tui/components/dialog/help.go+14−12
@@ -166,7 +166,7 @@ func (h *helpCmp) render() string {
166 return content
166 return content
167}
167}
168
168
169−func (h *helpCmp) View() string {
169+func (h *helpCmp) View() tea.View {
170 t := theme.CurrentTheme()
170 t := theme.CurrentTheme()
171 baseStyle := styles.BaseStyle()
171 baseStyle := styles.BaseStyle()
172
172
@@ -177,18 +177,20 @@ func (h *helpCmp) View() string {
177 Foreground(t.Primary()).
177 Foreground(t.Primary()).
178 Render("Keyboard Shortcuts")
178 Render("Keyboard Shortcuts")
179
179
180+ return tea.NewView(
180− return baseStyle.Padding(1).
181+ baseStyle.Padding(1).
181− Border(lipgloss.RoundedBorder()).
182+ Border(lipgloss.RoundedBorder()).
182− BorderForeground(t.TextMuted()).
183+ BorderForeground(t.TextMuted()).
183− Width(h.width).
184+ Width(h.width).
184− BorderBackground(t.Background()).
185+ BorderBackground(t.Background()).
185− Render(
186+ Render(
186− lipgloss.JoinVertical(lipgloss.Center,
187+ lipgloss.JoinVertical(lipgloss.Center,
187− header,
188+ header,
188− baseStyle.Render(strings.Repeat(" ", lipgloss.Width(header))),
189+ baseStyle.Render(strings.Repeat(" ", lipgloss.Width(header))),
189− content,
190+ content,
191+ ),
190 ),
192 ),
191− )
193+ )
192}
194}
193
195
194type HelpCmp interface {
196type HelpCmp interface {
internal/tui/components/dialog/models.go+9−7
@@ -185,7 +185,7 @@ func (m *modelDialogCmp) switchProvider(offset int) {
185 m.setupModelsForProvider(m.provider)
185 m.setupModelsForProvider(m.provider)
186}
186}
187
187
188−func (m *modelDialogCmp) View() string {
188+func (m *modelDialogCmp) View() tea.View {
189 t := theme.CurrentTheme()
189 t := theme.CurrentTheme()
190 baseStyle := styles.BaseStyle()
190 baseStyle := styles.BaseStyle()
191
191
@@ -220,12 +220,14 @@ func (m *modelDialogCmp) View() string {
220 scrollIndicator,
220 scrollIndicator,
221 )
221 )
222
222
223+ return tea.NewView(
223− return baseStyle.Padding(1, 2).
224+ baseStyle.Padding(1, 2).
224− Border(lipgloss.RoundedBorder()).
225+ Border(lipgloss.RoundedBorder()).
225− BorderBackground(t.Background()).
226+ BorderBackground(t.Background()).
226− BorderForeground(t.TextMuted()).
227+ BorderForeground(t.TextMuted()).
227− Width(lipgloss.Width(content) + 4).
228+ Width(lipgloss.Width(content) + 4).
228− Render(content)
229+ Render(content),
230+ )
229}
231}
230
232
231func (m *modelDialogCmp) getScrollIndicators(maxWidth int) string {
233func (m *modelDialogCmp) getScrollIndicators(maxWidth int) string {
internal/tui/components/dialog/permission.go+2−2
@@ -437,8 +437,8 @@ func (p *permissionDialogCmp) render() string {
437 )
437 )
438}
438}
439
439
440−func (p *permissionDialogCmp) View() string {
440+func (p *permissionDialogCmp) View() tea.View {
441− return p.render()
441+ return tea.NewView(p.render())
442}
442}
443
443
444func (p *permissionDialogCmp) BindingKeys() []key.Binding {
444func (p *permissionDialogCmp) BindingKeys() []key.Binding {
internal/tui/components/dialog/session.go+16−12
@@ -105,17 +105,19 @@ func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
105 return s, nil
105 return s, nil
106}
106}
107
107
108−func (s *sessionDialogCmp) View() string {
108+func (s *sessionDialogCmp) View() tea.View {
109 t := theme.CurrentTheme()
109 t := theme.CurrentTheme()
110 baseStyle := styles.BaseStyle()
110 baseStyle := styles.BaseStyle()
111
111
112 if len(s.sessions) == 0 {
112 if len(s.sessions) == 0 {
113+ return tea.NewView(
113− return baseStyle.Padding(1, 2).
114+ baseStyle.Padding(1, 2).
114− Border(lipgloss.RoundedBorder()).
115+ Border(lipgloss.RoundedBorder()).
115− BorderBackground(t.Background()).
116+ BorderBackground(t.Background()).
116− BorderForeground(t.TextMuted()).
117+ BorderForeground(t.TextMuted()).
117− Width(40).
118+ Width(40).
118− Render("No sessions available")
119+ Render("No sessions available"),
120+ )
119 }
121 }
120
122
121 // Calculate max width needed for session titles
123 // Calculate max width needed for session titles
@@ -177,11 +179,13 @@ func (s *sessionDialogCmp) View() string {
177 baseStyle.Width(maxWidth).Render(""),
179 baseStyle.Width(maxWidth).Render(""),
178 )
180 )
179
181
182+ return tea.NewView(
180− return baseStyle.Padding(1, 2).
183+ baseStyle.Padding(1, 2).
181− Border(lipgloss.RoundedBorder()).
184+ Border(lipgloss.RoundedBorder()).
182− BorderBackground(t.Background()).
185+ BorderBackground(t.Background()).
183− BorderForeground(t.TextMuted()).
186+ BorderForeground(t.TextMuted()).
184− Render(content)
187+ Render(content),
188+ )
185}
189}
186
190
187func (s *sessionDialogCmp) BindingKeys() []key.Binding {
191func (s *sessionDialogCmp) BindingKeys() []key.Binding {
internal/tui/components/dialog/theme.go+17−13
@@ -122,17 +122,19 @@ func (t *themeDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
122 return t, nil
122 return t, nil
123}
123}
124
124
125−func (t *themeDialogCmp) View() string {
125+func (t *themeDialogCmp) View() tea.View {
126 currentTheme := theme.CurrentTheme()
126 currentTheme := theme.CurrentTheme()
127 baseStyle := styles.BaseStyle()
127 baseStyle := styles.BaseStyle()
128
128
129 if len(t.themes) == 0 {
129 if len(t.themes) == 0 {
130+ return tea.NewView(
130− return baseStyle.Padding(1, 2).
131+ baseStyle.Padding(1, 2).
131− Border(lipgloss.RoundedBorder()).
132+ Border(lipgloss.RoundedBorder()).
132− BorderBackground(currentTheme.Background()).
133+ BorderBackground(currentTheme.Background()).
133− BorderForeground(currentTheme.TextMuted()).
134+ BorderForeground(currentTheme.TextMuted()).
134− Width(40).
135+ Width(40).
135− Render("No themes available")
136+ Render("No themes available"),
137+ )
136 }
138 }
137
139
138 // Calculate max width needed for theme names
140 // Calculate max width needed for theme names
@@ -175,12 +177,14 @@ func (t *themeDialogCmp) View() string {
175 baseStyle.Width(maxWidth).Render(""),
177 baseStyle.Width(maxWidth).Render(""),
176 )
178 )
177
179
180+ return tea.NewView(
178− return baseStyle.Padding(1, 2).
181+ baseStyle.Padding(1, 2).
179− Border(lipgloss.RoundedBorder()).
182+ Border(lipgloss.RoundedBorder()).
180− BorderBackground(currentTheme.Background()).
183+ BorderBackground(currentTheme.Background()).
181− BorderForeground(currentTheme.TextMuted()).
184+ BorderForeground(currentTheme.TextMuted()).
182− Width(lipgloss.Width(content) + 4).
185+ Width(lipgloss.Width(content) + 4).
183− Render(content)
186+ Render(content),
187+ )
184}
188}
185
189
186func (t *themeDialogCmp) BindingKeys() []key.Binding {
190func (t *themeDialogCmp) BindingKeys() []key.Binding {
internal/tui/components/dialogs/commands/arguments.gonew file+16
@@ -0,0 +1,16 @@
1+package commands
2+
3+// ShowArgumentsDialogMsg is a message that is sent to show the arguments dialog.
4+type ShowArgumentsDialogMsg struct {
5+ CommandID string
6+ Content string
7+ ArgNames []string
8+}
9+
10+// CloseArgumentsDialogMsg is a message that is sent when the arguments dialog is closed.
11+type CloseArgumentsDialogMsg struct {
12+ Submit bool
13+ CommandID string
14+ Content string
15+ Args map[string]string
16+}
internal/tui/components/dialogs/commands/commands.gonew file+135
@@ -0,0 +1,135 @@
1+package commands
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/textinput"
5+ tea "github.com/charmbracelet/bubbletea/v2"
6+ "github.com/charmbracelet/lipgloss/v2"
7+
8+ "github.com/opencode-ai/opencode/internal/logging"
9+ "github.com/opencode-ai/opencode/internal/tui/components/core/list"
10+ "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
11+ "github.com/opencode-ai/opencode/internal/tui/styles"
12+ "github.com/opencode-ai/opencode/internal/tui/theme"
13+ "github.com/opencode-ai/opencode/internal/tui/util"
14+)
15+
16+const (
17+ id dialogs.DialogID = "commands"
18+)
19+
20+// Command represents a command that can be executed
21+type Command struct {
22+ ID string
23+ Title string
24+ Description string
25+ Handler func(cmd Command) tea.Cmd
26+}
27+
28+// CommandsDialog represents the commands dialog.
29+type CommandsDialog interface {
30+ dialogs.DialogModel
31+}
32+
33+type commandDialogCmp struct {
34+ width int
35+ wWidth int // Width of the terminal window
36+ wHeight int // Height of the terminal window
37+
38+ commandList list.ListModel
39+ input textinput.Model
40+ oldCursor tea.Cursor
41+}
42+
43+func NewCommandDialog() CommandsDialog {
44+ ti := textinput.New()
45+ ti.Placeholder = "Type a command or search..."
46+ ti.SetVirtualCursor(false)
47+ ti.Focus()
48+ ti.SetWidth(60 - 7)
49+ commandList := list.New()
50+ return &commandDialogCmp{
51+ commandList: commandList,
52+ width: 60,
53+ input: ti,
54+ }
55+}
56+
57+func (c *commandDialogCmp) Init() tea.Cmd {
58+ logging.Info("Initializing commands dialog")
59+ commands, err := LoadCustomCommands()
60+ if err != nil {
61+ return util.ReportError(err)
62+ }
63+ logging.Info("Commands loaded", "count", len(commands))
64+
65+ commandItems := make([]util.Model, 0, len(commands))
66+
67+ for _, cmd := range commands {
68+ commandItems = append(commandItems, NewCommandItem(cmd))
69+ }
70+ c.commandList.SetItems(commandItems)
71+ return c.commandList.Init()
72+}
73+
74+func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
75+ switch msg := msg.(type) {
76+ case tea.WindowSizeMsg:
77+ c.wWidth = msg.Width
78+ c.wHeight = msg.Height
79+ return c, c.commandList.SetSize(60, min(len(c.commandList.Items())*2, c.wHeight/2))
80+ }
81+ u, cmd := c.input.Update(msg)
82+ c.input = u
83+ return c, cmd
84+}
85+
86+func (c *commandDialogCmp) View() tea.View {
87+ content := lipgloss.JoinVertical(
88+ lipgloss.Left,
89+ c.inputStyle().Render(c.input.View()),
90+ c.commandList.View().String(),
91+ )
92+
93+ v := tea.NewView(c.style().Render(content))
94+ v.SetCursor(c.getCursor())
95+ return v
96+}
97+
98+func (c *commandDialogCmp) getCursor() *tea.Cursor {
99+ cursor := c.input.Cursor()
100+ offset := 10 + 1
101+ cursor.Y += offset
102+ _, col := c.Position()
103+ cursor.X = c.input.Cursor().X + col + 2
104+ return cursor
105+}
106+
107+func (c *commandDialogCmp) inputStyle() lipgloss.Style {
108+ t := theme.CurrentTheme()
109+ return styles.BaseStyle().
110+ BorderStyle(lipgloss.NormalBorder()).
111+ BorderForeground(t.TextMuted()).
112+ BorderBackground(t.Background()).
113+ BorderBottom(true)
114+}
115+
116+func (c *commandDialogCmp) style() lipgloss.Style {
117+ t := theme.CurrentTheme()
118+ return styles.BaseStyle().
119+ Width(c.width).
120+ Padding(0, 1, 1, 1).
121+ Border(lipgloss.RoundedBorder()).
122+ BorderBackground(t.Background()).
123+ BorderForeground(t.TextMuted())
124+}
125+
126+func (q *commandDialogCmp) Position() (int, int) {
127+ row := 10
128+ col := q.wWidth / 2
129+ col -= q.width / 2
130+ return row, col
131+}
132+
133+func (c *commandDialogCmp) ID() dialogs.DialogID {
134+ return id
135+}
internal/tui/components/dialogs/commands/item.gonew file+55
@@ -0,0 +1,55 @@
1+package commands
2+
3+import (
4+ tea "github.com/charmbracelet/bubbletea/v2"
5+ "github.com/opencode-ai/opencode/internal/tui/layout"
6+ "github.com/opencode-ai/opencode/internal/tui/util"
7+)
8+
9+type CommandItem interface {
10+ util.Model
11+ layout.Focusable
12+}
13+
14+type commandItem struct {
15+ command Command
16+ focus bool
17+}
18+
19+func NewCommandItem(command Command) CommandItem {
20+ return &commandItem{
21+ command: command,
22+ }
23+}
24+
25+// Init implements CommandItem.
26+func (c *commandItem) Init() tea.Cmd {
27+ return nil
28+}
29+
30+// Update implements CommandItem.
31+func (c *commandItem) Update(tea.Msg) (tea.Model, tea.Cmd) {
32+ return c, nil
33+}
34+
35+// View implements CommandItem.
36+func (c *commandItem) View() tea.View {
37+ return tea.NewView(c.command.Title)
38+}
39+
40+// Blur implements CommandItem.
41+func (c *commandItem) Blur() tea.Cmd {
42+ c.focus = false
43+ return nil
44+}
45+
46+// Focus implements CommandItem.
47+func (c *commandItem) Focus() tea.Cmd {
48+ c.focus = true
49+ return nil
50+}
51+
52+// IsFocused implements CommandItem.
53+func (c *commandItem) IsFocused() bool {
54+ return c.focus
55+}
internal/tui/components/dialogs/commands/loader.gonew file+204
@@ -0,0 +1,204 @@
1+package commands
2+
3+import (
4+ "fmt"
5+ "io/fs"
6+ "os"
7+ "path/filepath"
8+ "regexp"
9+ "strings"
10+
11+ tea "github.com/charmbracelet/bubbletea/v2"
12+ "github.com/opencode-ai/opencode/internal/config"
13+ "github.com/opencode-ai/opencode/internal/tui/util"
14+)
15+
16+const (
17+ UserCommandPrefix = "user:"
18+ ProjectCommandPrefix = "project:"
19+)
20+
21+var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
22+
23+type commandLoader struct {
24+ sources []commandSource
25+}
26+
27+type commandSource struct {
28+ path string
29+ prefix string
30+}
31+
32+func LoadCustomCommands() ([]Command, error) {
33+ cfg := config.Get()
34+ if cfg == nil {
35+ return nil, fmt.Errorf("config not loaded")
36+ }
37+
38+ loader := &commandLoader{
39+ sources: buildCommandSources(cfg),
40+ }
41+
42+ return loader.loadAll()
43+}
44+
45+func buildCommandSources(cfg *config.Config) []commandSource {
46+ var sources []commandSource
47+
48+ // XDG config directory
49+ if dir := getXDGCommandsDir(); dir != "" {
50+ sources = append(sources, commandSource{
51+ path: dir,
52+ prefix: UserCommandPrefix,
53+ })
54+ }
55+
56+ // Home directory
57+ if home, err := os.UserHomeDir(); err == nil {
58+ sources = append(sources, commandSource{
59+ path: filepath.Join(home, ".opencode", "commands"),
60+ prefix: UserCommandPrefix,
61+ })
62+ }
63+
64+ // Project directory
65+ sources = append(sources, commandSource{
66+ path: filepath.Join(cfg.Data.Directory, "commands"),
67+ prefix: ProjectCommandPrefix,
68+ })
69+
70+ return sources
71+}
72+
73+func getXDGCommandsDir() string {
74+ xdgHome := os.Getenv("XDG_CONFIG_HOME")
75+ if xdgHome == "" {
76+ if home, err := os.UserHomeDir(); err == nil {
77+ xdgHome = filepath.Join(home, ".config")
78+ }
79+ }
80+ if xdgHome != "" {
81+ return filepath.Join(xdgHome, "opencode", "commands")
82+ }
83+ return ""
84+}
85+
86+func (l *commandLoader) loadAll() ([]Command, error) {
87+ var commands []Command
88+
89+ for _, source := range l.sources {
90+ if cmds, err := l.loadFromSource(source); err == nil {
91+ commands = append(commands, cmds...)
92+ }
93+ }
94+
95+ return commands, nil
96+}
97+
98+func (l *commandLoader) loadFromSource(source commandSource) ([]Command, error) {
99+ if err := ensureDir(source.path); err != nil {
100+ return nil, err
101+ }
102+
103+ var commands []Command
104+
105+ err := filepath.WalkDir(source.path, func(path string, d fs.DirEntry, err error) error {
106+ if err != nil || d.IsDir() || !isMarkdownFile(d.Name()) {
107+ return err
108+ }
109+
110+ cmd, err := l.loadCommand(path, source.path, source.prefix)
111+ if err != nil {
112+ return nil // Skip invalid files
113+ }
114+
115+ commands = append(commands, cmd)
116+ return nil
117+ })
118+
119+ return commands, err
120+}
121+
122+func (l *commandLoader) loadCommand(path, baseDir, prefix string) (Command, error) {
123+ content, err := os.ReadFile(path)
124+ if err != nil {
125+ return Command{}, err
126+ }
127+
128+ id := buildCommandID(path, baseDir, prefix)
129+
130+ return Command{
131+ ID: id,
132+ Title: id,
133+ Description: fmt.Sprintf("Custom command from %s", filepath.Base(path)),
134+ Handler: createCommandHandler(id, string(content)),
135+ }, nil
136+}
137+
138+func buildCommandID(path, baseDir, prefix string) string {
139+ relPath, _ := filepath.Rel(baseDir, path)
140+ parts := strings.Split(relPath, string(filepath.Separator))
141+
142+ // Remove .md extension from last part
143+ if len(parts) > 0 {
144+ lastIdx := len(parts) - 1
145+ parts[lastIdx] = strings.TrimSuffix(parts[lastIdx], filepath.Ext(parts[lastIdx]))
146+ }
147+
148+ return prefix + strings.Join(parts, ":")
149+}
150+
151+func createCommandHandler(id string, content string) func(Command) tea.Cmd {
152+ return func(cmd Command) tea.Cmd {
153+ args := extractArgNames(content)
154+
155+ if len(args) > 0 {
156+ return util.CmdHandler(ShowArgumentsDialogMsg{
157+ CommandID: id,
158+ Content: content,
159+ ArgNames: args,
160+ })
161+ }
162+
163+ return util.CmdHandler(CommandRunCustomMsg{
164+ Content: content,
165+ Args: nil,
166+ })
167+ }
168+}
169+
170+func extractArgNames(content string) []string {
171+ matches := namedArgPattern.FindAllStringSubmatch(content, -1)
172+ if len(matches) == 0 {
173+ return nil
174+ }
175+
176+ seen := make(map[string]bool)
177+ var args []string
178+
179+ for _, match := range matches {
180+ arg := match[1]
181+ if !seen[arg] {
182+ seen[arg] = true
183+ args = append(args, arg)
184+ }
185+ }
186+
187+ return args
188+}
189+
190+func ensureDir(path string) error {
191+ if _, err := os.Stat(path); os.IsNotExist(err) {
192+ return os.MkdirAll(path, 0755)
193+ }
194+ return nil
195+}
196+
197+func isMarkdownFile(name string) bool {
198+ return strings.HasSuffix(strings.ToLower(name), ".md")
199+}
200+
201+type CommandRunCustomMsg struct {
202+ Content string
203+ Args map[string]string
204+}
internal/tui/components/dialogs/dialogs.gonew file+164
@@ -0,0 +1,164 @@
1+package dialogs
2+
3+import (
4+ "slices"
5+
6+ "github.com/charmbracelet/bubbles/v2/key"
7+ tea "github.com/charmbracelet/bubbletea/v2"
8+ "github.com/charmbracelet/lipgloss/v2"
9+ "github.com/opencode-ai/opencode/internal/tui/util"
10+)
11+
12+type DialogID string
13+
14+// DialogModel represents a dialog component that can be displayed.
15+type DialogModel interface {
16+ util.Model
17+ Position() (int, int)
18+ ID() DialogID
19+}
20+
21+// CloseCallback allows dialogs to perform cleanup when closed.
22+type CloseCallback interface {
23+ Close() tea.Cmd
24+}
25+
26+// AbsolutePositionable is an interface for components that can set their position
27+type AbsolutePositionable interface {
28+ SetPosition(x, y int)
29+}
30+
31+// OpenDialogMsg is sent to open a new dialog with specified dimensions.
32+type OpenDialogMsg struct {
33+ Model DialogModel
34+}
35+
36+// CloseDialogMsg is sent to close the topmost dialog.
37+type CloseDialogMsg struct{}
38+
39+// DialogCmp manages a stack of dialogs with keyboard navigation.
40+type DialogCmp interface {
41+ tea.Model
42+
43+ Dialogs() []DialogModel
44+ HasDialogs() bool
45+ GetLayers() []*lipgloss.Layer
46+ ActiveView() *tea.View
47+}
48+
49+type dialogCmp struct {
50+ width, height int
51+ dialogs []DialogModel
52+ idMap map[DialogID]int
53+ keymap KeyMap
54+}
55+
56+// NewDialogCmp creates a new dialog manager.
57+func NewDialogCmp() DialogCmp {
58+ return dialogCmp{
59+ dialogs: []DialogModel{},
60+ keymap: DefaultKeymap(),
61+ idMap: make(map[DialogID]int),
62+ }
63+}
64+
65+func (d dialogCmp) Init() tea.Cmd {
66+ return nil
67+}
68+
69+// Update handles dialog lifecycle and forwards messages to the active dialog.
70+func (d dialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
71+ switch msg := msg.(type) {
72+ case tea.WindowSizeMsg:
73+ var cmds []tea.Cmd
74+ d.width = msg.Width
75+ d.height = msg.Height
76+ for i := range d.dialogs {
77+ u, cmd := d.dialogs[i].Update(msg)
78+ d.dialogs[i] = u.(DialogModel)
79+ cmds = append(cmds, cmd)
80+ }
81+ return d, tea.Batch(cmds...)
82+ case OpenDialogMsg:
83+ return d.handleOpen(msg)
84+ case CloseDialogMsg:
85+ if len(d.dialogs) == 0 {
86+ return d, nil
87+ }
88+ inx := len(d.dialogs) - 1
89+ dialog := d.dialogs[inx]
90+ delete(d.idMap, dialog.ID())
91+ d.dialogs = d.dialogs[:len(d.dialogs)-1]
92+ if closeable, ok := dialog.(CloseCallback); ok {
93+ return d, closeable.Close()
94+ }
95+ return d, nil
96+ case tea.KeyPressMsg:
97+ if key.Matches(msg, d.keymap.Close) {
98+ return d, util.CmdHandler(CloseDialogMsg{})
99+ }
100+ }
101+ if d.HasDialogs() {
102+ lastIndex := len(d.dialogs) - 1
103+ u, cmd := d.dialogs[lastIndex].Update(msg)
104+ d.dialogs[lastIndex] = u.(DialogModel)
105+ return d, cmd
106+ }
107+ return d, nil
108+}
109+
110+func (d dialogCmp) handleOpen(msg OpenDialogMsg) (tea.Model, tea.Cmd) {
111+ if d.HasDialogs() {
112+ dialog := d.dialogs[len(d.dialogs)-1]
113+ if dialog.ID() == msg.Model.ID() {
114+ return d, nil // Do not open a dialog if it's already the topmost one
115+ }
116+ if dialog.ID() == "quit" {
117+ return d, nil // Do not open dialogs ontop of quit
118+ }
119+ }
120+ // if the dialog is already in thel stack make it the last item
121+ if _, ok := d.idMap[msg.Model.ID()]; ok {
122+ existing := d.dialogs[d.idMap[msg.Model.ID()]]
123+ // Reuse the model so we keep the state
124+ msg.Model = existing
125+ d.dialogs = slices.Delete(d.dialogs, d.idMap[msg.Model.ID()], d.idMap[msg.Model.ID()]+1)
126+ }
127+ d.idMap[msg.Model.ID()] = len(d.dialogs)
128+ d.dialogs = append(d.dialogs, msg.Model)
129+ var cmds []tea.Cmd
130+ cmd := msg.Model.Init()
131+ cmds = append(cmds, cmd)
132+ _, cmd = msg.Model.Update(tea.WindowSizeMsg{
133+ Width: d.width,
134+ Height: d.height,
135+ })
136+ cmds = append(cmds, cmd)
137+ return d, tea.Batch(cmds...)
138+}
139+
140+func (d dialogCmp) Dialogs() []DialogModel {
141+ return d.dialogs
142+}
143+
144+func (d dialogCmp) ActiveView() *tea.View {
145+ if len(d.dialogs) == 0 {
146+ return nil
147+ }
148+ view := d.dialogs[len(d.dialogs)-1].View()
149+ return &view
150+}
151+
152+func (d dialogCmp) GetLayers() []*lipgloss.Layer {
153+ layers := []*lipgloss.Layer{}
154+ for _, dialog := range d.Dialogs() {
155+ dialogView := dialog.View().String()
156+ row, col := dialog.Position()
157+ layers = append(layers, lipgloss.NewLayer(dialogView).X(col).Y(row))
158+ }
159+ return layers
160+}
161+
162+func (d dialogCmp) HasDialogs() bool {
163+ return len(d.dialogs) > 0
164+}
internal/tui/components/dialogs/keys.gonew file+37
@@ -0,0 +1,37 @@
1+package dialogs
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ "github.com/opencode-ai/opencode/internal/tui/layout"
6+)
7+
8+// KeyMap defines keyboard bindings for dialog management.
9+type KeyMap struct {
10+ Close key.Binding
11+}
12+
13+func DefaultKeymap() KeyMap {
14+ return KeyMap{
15+ Close: key.NewBinding(
16+ key.WithKeys("esc"),
17+ ),
18+ }
19+}
20+
21+// FullHelp implements help.KeyMap.
22+func (k KeyMap) FullHelp() [][]key.Binding {
23+ m := [][]key.Binding{}
24+ slice := layout.KeyMapToSlice(k)
25+ for i := 0; i < len(slice); i += 4 {
26+ end := min(i+4, len(slice))
27+ m = append(m, slice[i:end])
28+ }
29+ return m
30+}
31+
32+// ShortHelp implements help.KeyMap.
33+func (k KeyMap) ShortHelp() []key.Binding {
34+ return []key.Binding{
35+ k.Close,
36+ }
37+}
internal/tui/components/dialogs/quit/keys.gonew file+59
@@ -0,0 +1,59 @@
1+package quit
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ "github.com/opencode-ai/opencode/internal/tui/layout"
6+)
7+
8+// KeyMap defines the keyboard bindings for the quit dialog.
9+type KeyMap struct {
10+ LeftRight key.Binding
11+ EnterSpace key.Binding
12+ Yes key.Binding
13+ No key.Binding
14+ Tab key.Binding
15+}
16+
17+func DefaultKeymap() KeyMap {
18+ return KeyMap{
19+ LeftRight: key.NewBinding(
20+ key.WithKeys("left", "right"),
21+ key.WithHelp("←/→", "switch options"),
22+ ),
23+ EnterSpace: key.NewBinding(
24+ key.WithKeys("enter", " "),
25+ key.WithHelp("enter/space", "confirm"),
26+ ),
27+ Yes: key.NewBinding(
28+ key.WithKeys("y", "Y", "ctrl+c"),
29+ key.WithHelp("y/Y/ctrl+c", "yes"),
30+ ),
31+ No: key.NewBinding(
32+ key.WithKeys("n", "N"),
33+ key.WithHelp("n/N", "no"),
34+ ),
35+ Tab: key.NewBinding(
36+ key.WithKeys("tab"),
37+ key.WithHelp("tab", "switch options"),
38+ ),
39+ }
40+}
41+
42+// FullHelp implements help.KeyMap.
43+func (k KeyMap) FullHelp() [][]key.Binding {
44+ m := [][]key.Binding{}
45+ slice := layout.KeyMapToSlice(k)
46+ for i := 0; i < len(slice); i += 4 {
47+ end := min(i+4, len(slice))
48+ m = append(m, slice[i:end])
49+ }
50+ return m
51+}
52+
53+// ShortHelp implements help.KeyMap.
54+func (k KeyMap) ShortHelp() []key.Binding {
55+ return []key.Binding{
56+ k.LeftRight,
57+ k.EnterSpace,
58+ }
59+}
internal/tui/components/dialog/quit.go → internal/tui/components/dialogs/quit/quit.gorenamed+48−58
@@ -1,87 +1,74 @@
1−package dialog
1+package quit
2
2
3import (
3import (
4− "strings"
5−
6 "github.com/charmbracelet/bubbles/v2/key"
4 "github.com/charmbracelet/bubbles/v2/key"
7 tea "github.com/charmbracelet/bubbletea/v2"
5 tea "github.com/charmbracelet/bubbletea/v2"
8 "github.com/charmbracelet/lipgloss/v2"
6 "github.com/charmbracelet/lipgloss/v2"
7+ "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
9 "github.com/opencode-ai/opencode/internal/tui/layout"
8 "github.com/opencode-ai/opencode/internal/tui/layout"
10 "github.com/opencode-ai/opencode/internal/tui/styles"
9 "github.com/opencode-ai/opencode/internal/tui/styles"
11 "github.com/opencode-ai/opencode/internal/tui/theme"
10 "github.com/opencode-ai/opencode/internal/tui/theme"
12 "github.com/opencode-ai/opencode/internal/tui/util"
11 "github.com/opencode-ai/opencode/internal/tui/util"
13)
12)
14
13
14+const (
15−const question = "Are you sure you want to quit?"
15+ question = "Are you sure you want to quit?"
16−
16+ id dialogs.DialogID = "quit"
17−type CloseQuitMsg struct{}
17+)
18
18
19+// QuitDialog represents a confirmation dialog for quitting the application.
19type QuitDialog interface {
20type QuitDialog interface {
20− util.Model
21+ dialogs.DialogModel
21 layout.Bindings
22 layout.Bindings
22}
23}
23
24
24type quitDialogCmp struct {
25type quitDialogCmp struct {
25− selectedNo bool
26+ wWidth int
26−}
27+ wHeight int
27
28
28−type helpMapping struct {
29+ selectedNo bool // true if "No" button is selected
29− LeftRight key.Binding
30+ keymap KeyMap
30− EnterSpace key.Binding
31− Yes key.Binding
32− No key.Binding
33− Tab key.Binding
34}
31}
35
32
36−var helpKeys = helpMapping{
33+// NewQuitDialog creates a new quit confirmation dialog.
37− LeftRight: key.NewBinding(
34+func NewQuitDialog() QuitDialog {
38− key.WithKeys("left", "right"),
35+ return &quitDialogCmp{
39− key.WithHelp("←/→", "switch options"),
36+ selectedNo: true, // Default to "No" for safety
40− ),
37+ keymap: DefaultKeymap(),
41− EnterSpace: key.NewBinding(
38+ }
42− key.WithKeys("enter", " "),
43− key.WithHelp("enter/space", "confirm"),
44− ),
45− Yes: key.NewBinding(
46− key.WithKeys("y", "Y"),
47− key.WithHelp("y/Y", "yes"),
48− ),
49− No: key.NewBinding(
50− key.WithKeys("n", "N"),
51− key.WithHelp("n/N", "no"),
52− ),
53− Tab: key.NewBinding(
54− key.WithKeys("tab"),
55− key.WithHelp("tab", "switch options"),
56− ),
57}
39}
58
40
59func (q *quitDialogCmp) Init() tea.Cmd {
41func (q *quitDialogCmp) Init() tea.Cmd {
60 return nil
42 return nil
61}
43}
62
44
45+// Update handles keyboard input for the quit dialog.
63func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
46func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
64 switch msg := msg.(type) {
47 switch msg := msg.(type) {
48+ case tea.WindowSizeMsg:
49+ q.wWidth = msg.Width
50+ q.wHeight = msg.Height
65 case tea.KeyPressMsg:
51 case tea.KeyPressMsg:
66 switch {
52 switch {
67− case key.Matches(msg, helpKeys.LeftRight) || key.Matches(msg, helpKeys.Tab):
53+ case key.Matches(msg, q.keymap.LeftRight) || key.Matches(msg, q.keymap.Tab):
68 q.selectedNo = !q.selectedNo
54 q.selectedNo = !q.selectedNo
69 return q, nil
55 return q, nil
70− case key.Matches(msg, helpKeys.EnterSpace):
56+ case key.Matches(msg, q.keymap.EnterSpace):
71 if !q.selectedNo {
57 if !q.selectedNo {
72 return q, tea.Quit
58 return q, tea.Quit
73 }
59 }
74− return q, util.CmdHandler(CloseQuitMsg{})
60+ return q, util.CmdHandler(dialogs.CloseDialogMsg{})
75− case key.Matches(msg, helpKeys.Yes):
61+ case key.Matches(msg, q.keymap.Yes):
76 return q, tea.Quit
62 return q, tea.Quit
77− case key.Matches(msg, helpKeys.No):
63+ case key.Matches(msg, q.keymap.No):
78− return q, util.CmdHandler(CloseQuitMsg{})
64+ return q, util.CmdHandler(dialogs.CloseDialogMsg{})
79 }
65 }
80 }
66 }
81 return q, nil
67 return q, nil
82}
68}
83
69
70+// View renders the quit dialog with Yes/No buttons.
84−func (q *quitDialogCmp) View() string {
71+func (q *quitDialogCmp) View() tea.View {
85 t := theme.CurrentTheme()
72 t := theme.CurrentTheme()
86 baseStyle := styles.BaseStyle()
73 baseStyle := styles.BaseStyle()
87
74
@@ -100,13 +87,9 @@ func (q *quitDialogCmp) View() string {
100 yesButton := yesStyle.Padding(0, 1).Render("Yes")
87 yesButton := yesStyle.Padding(0, 1).Render("Yes")
101 noButton := noStyle.Padding(0, 1).Render("No")
88 noButton := noStyle.Padding(0, 1).Render("No")
102
89
90+ buttons := baseStyle.Width(lipgloss.Width(question)).Align(lipgloss.Right).Render(
103− buttons := lipgloss.JoinHorizontal(lipgloss.Left, yesButton, spacerStyle.Render(" "), noButton)
91+ lipgloss.JoinHorizontal(lipgloss.Center, yesButton, spacerStyle.Render(" "), noButton),
104−
92+ )
105− width := lipgloss.Width(question)
106− remainingWidth := width - lipgloss.Width(buttons)
107− if remainingWidth > 0 {
108− buttons = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + buttons
109− }
110
93
111 content := baseStyle.Render(
94 content := baseStyle.Render(
112 lipgloss.JoinVertical(
95 lipgloss.JoinVertical(
@@ -123,17 +106,24 @@ func (q *quitDialogCmp) View() string {
123 BorderBackground(t.Background()).
106 BorderBackground(t.Background()).
124 BorderForeground(t.TextMuted())
107 BorderForeground(t.TextMuted())
125
108
126− return quitDialogStyle.
109+ return tea.NewView(
127− Width(lipgloss.Width(content) + quitDialogStyle.GetHorizontalFrameSize()).
128− Render(content)
110+ quitDialogStyle.Render(content),
111+ )
129}
112}
130
113
131func (q *quitDialogCmp) BindingKeys() []key.Binding {
114func (q *quitDialogCmp) BindingKeys() []key.Binding {
132− return layout.KeyMapToSlice(helpKeys)
115+ return layout.KeyMapToSlice(q.keymap)
133}
116}
134
117
135−func NewQuitCmp() QuitDialog {
118+func (q *quitDialogCmp) Position() (int, int) {
136− return &quitDialogCmp{
119+ row := q.wHeight / 2
137− selectedNo: true,
120+ row -= 7 / 2
121+ col := q.wWidth / 2
122+ col -= (lipgloss.Width(question) + 4) / 2
123+
124+ return row, col
138− }
125+}
126+
127+func (q *quitDialogCmp) ID() dialogs.DialogID {
128+ return id
139}
129}
internal/tui/components/logs/details.go+2−2
@@ -114,8 +114,8 @@ func getLevelStyle(level string) lipgloss.Style {
114 }
114 }
115}
115}
116
116
117−func (i *detailCmp) View() string {
117+func (i *detailCmp) View() tea.View {
118− return i.viewport.View()
118+ return tea.NewView(i.viewport.View())
119}
119}
120
120
121func (i *detailCmp) GetSize() (int, int) {
121func (i *detailCmp) GetSize() (int, int) {
internal/tui/components/logs/table.go+2−2
@@ -60,12 +60,12 @@ func (i *tableCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
60 return i, tea.Batch(cmds...)
60 return i, tea.Batch(cmds...)
61}
61}
62
62
63−func (i *tableCmp) View() string {
63+func (i *tableCmp) View() tea.View {
64 t := theme.CurrentTheme()
64 t := theme.CurrentTheme()
65 defaultStyles := table.DefaultStyles()
65 defaultStyles := table.DefaultStyles()
66 defaultStyles.Selected = defaultStyles.Selected.Foreground(t.Primary())
66 defaultStyles.Selected = defaultStyles.Selected.Foreground(t.Primary())
67 i.table.SetStyles(defaultStyles)
67 i.table.SetStyles(defaultStyles)
68− return i.table.View()
68+ return tea.NewView(i.table.View())
69}
69}
70
70
71func (i *tableCmp) GetSize() (int, int) {
71func (i *tableCmp) GetSize() (int, int) {
internal/tui/components/util/simple-list.go+11−7
@@ -110,7 +110,7 @@ func (c *simpleListCmp[T]) SetMaxWidth(width int) {
110 c.maxWidth = width
110 c.maxWidth = width
111}
111}
112
112
113−func (c *simpleListCmp[T]) View() string {
113+func (c *simpleListCmp[T]) View() tea.View {
114 t := theme.CurrentTheme()
114 t := theme.CurrentTheme()
115 baseStyle := styles.BaseStyle()
115 baseStyle := styles.BaseStyle()
116
116
@@ -120,11 +120,13 @@ func (c *simpleListCmp[T]) View() string {
120 startIdx := 0
120 startIdx := 0
121
121
122 if len(items) <= 0 {
122 if len(items) <= 0 {
123+ return tea.NewView(
123− return baseStyle.
124+ baseStyle.
124− Background(t.Background()).
125+ Background(t.Background()).
125− Padding(0, 1).
126+ Padding(0, 1).
126− Width(maxWidth).
127+ Width(maxWidth).
127− Render(c.fallbackMsg)
128+ Render(c.fallbackMsg),
129+ )
128 }
130 }
129
131
130 if len(items) > maxVisibleItems {
132 if len(items) > maxVisibleItems {
@@ -146,7 +148,9 @@ func (c *simpleListCmp[T]) View() string {
146 listItems = append(listItems, title)
148 listItems = append(listItems, title)
147 }
149 }
148
150
151+ return tea.NewView(
149− return lipgloss.JoinVertical(lipgloss.Left, listItems...)
152+ lipgloss.JoinVertical(lipgloss.Left, listItems...),
153+ )
150}
154}
151
155
152func NewSimpleList[T SimpleListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) SimpleList[T] {
156func NewSimpleList[T SimpleListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) SimpleList[T] {
internal/tui/keys.gonew file+75
@@ -0,0 +1,75 @@
1+package tui
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ "github.com/opencode-ai/opencode/internal/tui/layout"
6+)
7+
8+type KeyMap struct {
9+ Logs key.Binding
10+ Quit key.Binding
11+ Help key.Binding
12+ SwitchSession key.Binding
13+ Commands key.Binding
14+ FilePicker key.Binding
15+ Models key.Binding
16+ SwitchTheme key.Binding
17+}
18+
19+func DefaultKeyMap() KeyMap {
20+ return KeyMap{
21+ Logs: key.NewBinding(
22+ key.WithKeys("ctrl+l"),
23+ key.WithHelp("ctrl+l", "logs"),
24+ ),
25+
26+ Quit: key.NewBinding(
27+ key.WithKeys("ctrl+c"),
28+ key.WithHelp("ctrl+c", "quit"),
29+ ),
30+
31+ Help: key.NewBinding(
32+ key.WithKeys("ctrl+_"),
33+ key.WithHelp("ctrl+?", "toggle help"),
34+ ),
35+
36+ SwitchSession: key.NewBinding(
37+ key.WithKeys("ctrl+s"),
38+ key.WithHelp("ctrl+s", "switch session"),
39+ ),
40+
41+ Commands: key.NewBinding(
42+ key.WithKeys("ctrl+k"),
43+ key.WithHelp("ctrl+k", "commands"),
44+ ),
45+ FilePicker: key.NewBinding(
46+ key.WithKeys("ctrl+f"),
47+ key.WithHelp("ctrl+f", "select files to upload"),
48+ ),
49+ Models: key.NewBinding(
50+ key.WithKeys("ctrl+o"),
51+ key.WithHelp("ctrl+o", "model selection"),
52+ ),
53+
54+ SwitchTheme: key.NewBinding(
55+ key.WithKeys("ctrl+t"),
56+ key.WithHelp("ctrl+t", "switch theme"),
57+ ),
58+ }
59+}
60+
61+// FullHelp implements help.KeyMap.
62+func (k KeyMap) FullHelp() [][]key.Binding {
63+ m := [][]key.Binding{}
64+ slice := layout.KeyMapToSlice(k)
65+ for i := 0; i < len(slice); i += 4 {
66+ end := min(i+4, len(slice))
67+ m = append(m, slice[i:end])
68+ }
69+ return m
70+}
71+
72+// ShortHelp implements help.KeyMap.
73+func (k KeyMap) ShortHelp() []key.Binding {
74+ return []key.Binding{}
75+}
internal/tui/layout/container.go+17−2
@@ -12,11 +12,14 @@ type Container interface {
12 util.Model
12 util.Model
13 Sizeable
13 Sizeable
14 Bindings
14 Bindings
15+ Positionable
15}
16}
16type container struct {
17type container struct {
17 width int
18 width int
18 height int
19 height int
19
20
21+ x, y int
22+
20 content util.Model
23 content util.Model
21
24
22 // Style options
25 // Style options
@@ -42,7 +45,7 @@ func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
42 return c, cmd
45 return c, cmd
43}
46}
44
47
45−func (c *container) View() string {
48+func (c *container) View() tea.View {
46 t := theme.CurrentTheme()
49 t := theme.CurrentTheme()
47 style := lipgloss.NewStyle()
50 style := lipgloss.NewStyle()
48 width := c.width
51 width := c.width
@@ -76,7 +79,10 @@ func (c *container) View() string {
76 PaddingBottom(c.paddingBottom).
79 PaddingBottom(c.paddingBottom).
77 PaddingLeft(c.paddingLeft)
80 PaddingLeft(c.paddingLeft)
78
81
79− return style.Render(c.content.View())
82+ contentView := c.content.View()
83+ view := tea.NewView(style.Render(contentView.String()))
84+ view.SetCursor(contentView.Cursor())
85+ return view
80}
86}
81
87
82func (c *container) SetSize(width, height int) tea.Cmd {
88func (c *container) SetSize(width, height int) tea.Cmd {
@@ -115,6 +121,15 @@ func (c *container) GetSize() (int, int) {
115 return c.width, c.height
121 return c.width, c.height
116}
122}
117
123
124+func (c *container) SetPosition(x, y int) tea.Cmd {
125+ c.x = x
126+ c.y = y
127+ if positionable, ok := c.content.(Positionable); ok {
128+ return positionable.SetPosition(x, y)
129+ }
130+ return nil
131+}
132+
118func (c *container) BindingKeys() []key.Binding {
133func (c *container) BindingKeys() []key.Binding {
119 if b, ok := c.content.(Bindings); ok {
134 if b, ok := c.content.(Bindings); ok {
120 return b.BindingKeys()
135 return b.BindingKeys()
internal/tui/layout/layout.go+4
@@ -22,6 +22,10 @@ type Bindings interface {
22 BindingKeys() []key.Binding
22 BindingKeys() []key.Binding
23}
23}
24
24
25+type Positionable interface {
26+ SetPosition(x, y int) tea.Cmd
27+}
28+
25func KeyMapToSlice(t any) (bindings []key.Binding) {
29func KeyMapToSlice(t any) (bindings []key.Binding) {
26 typ := reflect.TypeOf(t)
30 typ := reflect.TypeOf(t)
27 if typ.Kind() != reflect.Struct {
31 if typ.Kind() != reflect.Struct {
internal/tui/layout/split.go+33−16
@@ -86,17 +86,17 @@ func (s *splitPaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
86 return s, tea.Batch(cmds...)
86 return s, tea.Batch(cmds...)
87}
87}
88
88
89−func (s *splitPaneLayout) View() string {
89+func (s *splitPaneLayout) View() tea.View {
90 var topSection string
90 var topSection string
91
91
92 if s.leftPanel != nil && s.rightPanel != nil {
92 if s.leftPanel != nil && s.rightPanel != nil {
93 leftView := s.leftPanel.View()
93 leftView := s.leftPanel.View()
94 rightView := s.rightPanel.View()
94 rightView := s.rightPanel.View()
95− topSection = lipgloss.JoinHorizontal(lipgloss.Top, leftView, rightView)
95+ topSection = lipgloss.JoinHorizontal(lipgloss.Top, leftView.String(), rightView.String())
96 } else if s.leftPanel != nil {
96 } else if s.leftPanel != nil {
97− topSection = s.leftPanel.View()
97+ topSection = s.leftPanel.View().String()
98 } else if s.rightPanel != nil {
98 } else if s.rightPanel != nil {
99− topSection = s.rightPanel.View()
99+ topSection = s.rightPanel.View().String()
100 } else {
100 } else {
101 topSection = ""
101 topSection = ""
102 }
102 }
@@ -105,25 +105,33 @@ func (s *splitPaneLayout) View() string {
105
105
106 if s.bottomPanel != nil && topSection != "" {
106 if s.bottomPanel != nil && topSection != "" {
107 bottomView := s.bottomPanel.View()
107 bottomView := s.bottomPanel.View()
108− finalView = lipgloss.JoinVertical(lipgloss.Left, topSection, bottomView)
108+ finalView = lipgloss.JoinVertical(lipgloss.Left, topSection, bottomView.String())
109 } else if s.bottomPanel != nil {
109 } else if s.bottomPanel != nil {
110− finalView = s.bottomPanel.View()
110+ finalView = s.bottomPanel.View().String()
111 } else {
111 } else {
112 finalView = topSection
112 finalView = topSection
113 }
113 }
114
114
115− if finalView != "" {
115+ // TODO: think of a better way to handle multiple cursors
116− t := theme.CurrentTheme()
116+ var cursor *tea.Cursor
117+ if s.bottomPanel != nil {
118+ cursor = s.bottomPanel.View().Cursor()
119+ } else if s.rightPanel != nil {
120+ cursor = s.rightPanel.View().Cursor()
121+ } else if s.leftPanel != nil {
122+ cursor = s.leftPanel.View().Cursor()
123+ }
117
124
118− style := lipgloss.NewStyle().
125+ t := theme.CurrentTheme()
119− Width(s.width).
120− Height(s.height).
121− Background(t.Background())
122
126
123− return style.Render(finalView)
127+ style := lipgloss.NewStyle().
124− }
128+ Width(s.width).
129+ Height(s.height).
130+ Background(t.Background())
125
131
126− return finalView
132+ view := tea.NewView(style.Render(finalView))
133+ view.SetCursor(cursor)
134+ return view
127}
135}
128
136
129func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
137func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
@@ -131,6 +139,7 @@ func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
131 s.height = height
139 s.height = height
132
140
133 var topHeight, bottomHeight int
141 var topHeight, bottomHeight int
142+ var cmds []tea.Cmd
134 if s.bottomPanel != nil {
143 if s.bottomPanel != nil {
135 topHeight = int(float64(height) * s.verticalRatio)
144 topHeight = int(float64(height) * s.verticalRatio)
136 bottomHeight = height - topHeight
145 bottomHeight = height - topHeight
@@ -151,20 +160,28 @@ func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
151 rightWidth = width
160 rightWidth = width
152 }
161 }
153
162
154− var cmds []tea.Cmd
155 if s.leftPanel != nil {
163 if s.leftPanel != nil {
156 cmd := s.leftPanel.SetSize(leftWidth, topHeight)
164 cmd := s.leftPanel.SetSize(leftWidth, topHeight)
157 cmds = append(cmds, cmd)
165 cmds = append(cmds, cmd)
166+ if positionable, ok := s.leftPanel.(Positionable); ok {
167+ cmds = append(cmds, positionable.SetPosition(0, 0))
168+ }
158 }
169 }
159
170
160 if s.rightPanel != nil {
171 if s.rightPanel != nil {
161 cmd := s.rightPanel.SetSize(rightWidth, topHeight)
172 cmd := s.rightPanel.SetSize(rightWidth, topHeight)
162 cmds = append(cmds, cmd)
173 cmds = append(cmds, cmd)
174+ if positionable, ok := s.rightPanel.(Positionable); ok {
175+ cmds = append(cmds, positionable.SetPosition(leftWidth, 0))
176+ }
163 }
177 }
164
178
165 if s.bottomPanel != nil {
179 if s.bottomPanel != nil {
166 cmd := s.bottomPanel.SetSize(width, bottomHeight)
180 cmd := s.bottomPanel.SetSize(width, bottomHeight)
167 cmds = append(cmds, cmd)
181 cmds = append(cmds, cmd)
182+ if positionable, ok := s.bottomPanel.(Positionable); ok {
183+ cmds = append(cmds, positionable.SetPosition(0, topHeight))
184+ }
168 }
185 }
169 return tea.Batch(cmds...)
186 return tea.Batch(cmds...)
170}
187}
internal/tui/page/chat.go+10−5
@@ -185,7 +185,7 @@ func (p *chatPage) GetSize() (int, int) {
185 return p.layout.GetSize()
185 return p.layout.GetSize()
186}
186}
187
187
188−func (p *chatPage) View() string {
188+func (p *chatPage) View() tea.View {
189 layoutView := p.layout.View()
189 layoutView := p.layout.View()
190
190
191 if p.showCompletionDialog {
191 if p.showCompletionDialog {
@@ -195,15 +195,20 @@ func (p *chatPage) View() string {
195 p.completionDialog.SetWidth(editorWidth)
195 p.completionDialog.SetWidth(editorWidth)
196 overlay := p.completionDialog.View()
196 overlay := p.completionDialog.View()
197
197
198− layoutView = layout.PlaceOverlay(
198+ viewStr := layout.PlaceOverlay(
199 0,
199 0,
200− layoutHeight-editorHeight-lipgloss.Height(overlay),
200+ layoutHeight-editorHeight-lipgloss.Height(overlay.String()),
201− overlay,
201+ overlay.String(),
202− layoutView,
202+ layoutView.String(),
203 false,
203 false,
204 )
204 )
205+
206+ view := tea.NewView(viewStr)
207+ view.SetCursor(overlay.Cursor())
208+ return view
205 }
209 }
206
210
211+ logging.Info("Cursor in page", "c", layoutView.Cursor())
207 return layoutView
212 return layoutView
208}
213}
209
214
internal/tui/page/logs.go+9−5
@@ -42,12 +42,16 @@ func (p *logsPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
42 return p, tea.Batch(cmds...)
42 return p, tea.Batch(cmds...)
43}
43}
44
44
45−func (p *logsPage) View() string {
45+func (p *logsPage) View() tea.View {
46 style := styles.BaseStyle().Width(p.width).Height(p.height)
46 style := styles.BaseStyle().Width(p.width).Height(p.height)
47+ return tea.NewView(
48+ style.Render(
47− return style.Render(lipgloss.JoinVertical(lipgloss.Top,
49+ lipgloss.JoinVertical(lipgloss.Top,
48− p.table.View(),
50+ p.table.View().String(),
49− p.details.View(),
51+ p.details.View().String(),
52+ ),
53+ ),
50− ))
54+ )
51}
55}
52
56
53func (p *logsPage) BindingKeys() []key.Binding {
57func (p *logsPage) BindingKeys() []key.Binding {
internal/tui/tui.go+821−851
@@ -1,676 +1,635 @@
1package tui
1package tui
2
2
3import (
3import (
4− "context"
5− "fmt"
6− "strings"
7−
8 "github.com/charmbracelet/bubbles/v2/key"
4 "github.com/charmbracelet/bubbles/v2/key"
9 tea "github.com/charmbracelet/bubbletea/v2"
5 tea "github.com/charmbracelet/bubbletea/v2"
10 "github.com/charmbracelet/lipgloss/v2"
6 "github.com/charmbracelet/lipgloss/v2"
11 "github.com/opencode-ai/opencode/internal/app"
7 "github.com/opencode-ai/opencode/internal/app"
12− "github.com/opencode-ai/opencode/internal/config"
13− "github.com/opencode-ai/opencode/internal/llm/agent"
14 "github.com/opencode-ai/opencode/internal/logging"
8 "github.com/opencode-ai/opencode/internal/logging"
15− "github.com/opencode-ai/opencode/internal/permission"
16 "github.com/opencode-ai/opencode/internal/pubsub"
9 "github.com/opencode-ai/opencode/internal/pubsub"
17− "github.com/opencode-ai/opencode/internal/session"
18− "github.com/opencode-ai/opencode/internal/tui/components/chat"
19 "github.com/opencode-ai/opencode/internal/tui/components/core"
10 "github.com/opencode-ai/opencode/internal/tui/components/core"
20− "github.com/opencode-ai/opencode/internal/tui/components/dialog"
11+ "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
12+ "github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands"
13+ "github.com/opencode-ai/opencode/internal/tui/components/dialogs/quit"
21 "github.com/opencode-ai/opencode/internal/tui/layout"
14 "github.com/opencode-ai/opencode/internal/tui/layout"
22 "github.com/opencode-ai/opencode/internal/tui/page"
15 "github.com/opencode-ai/opencode/internal/tui/page"
23 "github.com/opencode-ai/opencode/internal/tui/theme"
16 "github.com/opencode-ai/opencode/internal/tui/theme"
24 "github.com/opencode-ai/opencode/internal/tui/util"
17 "github.com/opencode-ai/opencode/internal/tui/util"
25)
18)
26
19
27−type keyMap struct {
28− Logs key.Binding
29− Quit key.Binding
30− Help key.Binding
31− SwitchSession key.Binding
32− Commands key.Binding
33− Filepicker key.Binding
34− Models key.Binding
35− SwitchTheme key.Binding
36−}
37−
38−type startCompactSessionMsg struct{}
20+// type startCompactSessionMsg struct{}
39−
40−const (
41− quitKey = "q"
42−)
43−
44−var keys = keyMap{
45− Logs: key.NewBinding(
46− key.WithKeys("ctrl+l"),
47− key.WithHelp("ctrl+l", "logs"),
48− ),
49−
50− Quit: key.NewBinding(
51− key.WithKeys("ctrl+c"),
52− key.WithHelp("ctrl+c", "quit"),
53− ),
54− Help: key.NewBinding(
55− key.WithKeys("ctrl+_"),
56− key.WithHelp("ctrl+?", "toggle help"),
57− ),
58−
59− SwitchSession: key.NewBinding(
60− key.WithKeys("ctrl+s"),
61− key.WithHelp("ctrl+s", "switch session"),
62− ),
63−
64− Commands: key.NewBinding(
65− key.WithKeys("ctrl+k"),
66− key.WithHelp("ctrl+k", "commands"),
67− ),
68− Filepicker: key.NewBinding(
69− key.WithKeys("ctrl+f"),
70− key.WithHelp("ctrl+f", "select files to upload"),
71− ),
72− Models: key.NewBinding(
73− key.WithKeys("ctrl+o"),
74− key.WithHelp("ctrl+o", "model selection"),
75− ),
76−
77− SwitchTheme: key.NewBinding(
78− key.WithKeys("ctrl+t"),
79− key.WithHelp("ctrl+t", "switch theme"),
80− ),
81−}
82−
83−var helpEsc = key.NewBinding(
84− key.WithKeys("?"),
85− key.WithHelp("?", "toggle help"),
86−)
87−
88−var returnKey = key.NewBinding(
89− key.WithKeys("esc"),
90− key.WithHelp("esc", "close"),
91−)
92−
93−var logsKeyReturnKey = key.NewBinding(
94− key.WithKeys("esc", "backspace", quitKey),
95− key.WithHelp("esc/q", "go back"),
96−)
97
21
98type appModel struct {
22type appModel struct {
99− width, height int
23+ width, height int
24+ keyMap KeyMap
25+
100− currentPage page.PageID
26+ currentPage page.PageID
101− previousPage page.PageID
27+ previousPage page.PageID
102− pages map[page.PageID]util.Model
28+ pages map[page.PageID]util.Model
103− loadedPages map[page.PageID]bool
29+ loadedPages map[page.PageID]bool
30+
104− status core.StatusCmp
31+ status core.StatusCmp
32+
105− app *app.App
33+ app *app.App
34+
106− selectedSession session.Session
35+ // selectedSession session.Session
107−
36+ //
108− showPermissions bool
37+ // showPermissions bool
109− permissions dialog.PermissionDialogCmp
38+ // permissions dialog.PermissionDialogCmp
110−
39+ //
111− showHelp bool
40+ // showHelp bool
112− help dialog.HelpCmp
41+ // help dialog.HelpCmp
113−
42+ //
114− showQuit bool
115− quit dialog.QuitDialog
116−
117− showSessionDialog bool
43+ // showSessionDialog bool
118− sessionDialog dialog.SessionDialog
44+ // sessionDialog dialog.SessionDialog
119−
45+ //
120− showCommandDialog bool
46+ // showCommandDialog bool
121− commandDialog dialog.CommandDialog
47+ // commandDialog dialog.CommandDialog
122− commands []dialog.Command
48+ // commands []dialog.Command
123−
49+ //
124− showModelDialog bool
50+ // showModelDialog bool
125− modelDialog dialog.ModelDialog
51+ // modelDialog dialog.ModelDialog
126−
52+ //
127− showInitDialog bool
53+ // showInitDialog bool
128− initDialog dialog.InitDialogCmp
54+ // initDialog dialog.InitDialogCmp
129−
55+ //
130− showFilepicker bool
56+ // showFilepicker bool
131− filepicker dialog.FilepickerCmp
57+ // filepicker dialog.FilepickerCmp
132−
58+ //
133− showThemeDialog bool
59+ // showThemeDialog bool
134− themeDialog dialog.ThemeDialog
60+ // themeDialog dialog.ThemeDialog
135−
61+ //
136− showMultiArgumentsDialog bool
62+ // showMultiArgumentsDialog bool
137− multiArgumentsDialog dialog.MultiArgumentsDialogCmp
63+ // multiArgumentsDialog dialog.MultiArgumentsDialogCmp
138−
64+ //
139− isCompacting bool
65+ // isCompacting bool
140− compactingMessage string
66+ // compactingMessage string
67+
68+ // NEW DIALOG
69+ dialog dialogs.DialogCmp
141}
70}
142
71
143func (a appModel) Init() tea.Cmd {
72func (a appModel) Init() tea.Cmd {
144 var cmds []tea.Cmd
73 var cmds []tea.Cmd
145 cmd := a.pages[a.currentPage].Init()
74 cmd := a.pages[a.currentPage].Init()
146− t := theme.CurrentTheme()
147− cmds = append(cmds, tea.SetBackgroundColor(t.Background()))
148− a.loadedPages[a.currentPage] = true
149 cmds = append(cmds, cmd)
75 cmds = append(cmds, cmd)
76+ a.loadedPages[a.currentPage] = true
77+
150 cmd = a.status.Init()
78 cmd = a.status.Init()
151 cmds = append(cmds, cmd)
79 cmds = append(cmds, cmd)
152− cmd = a.quit.Init()
153− cmds = append(cmds, cmd)
154− cmd = a.help.Init()
80+ // cmd = a.help.Init()
155− cmds = append(cmds, cmd)
81+ // cmds = append(cmds, cmd)
156− cmd = a.sessionDialog.Init()
82+ // cmd = a.sessionDialog.Init()
157− cmds = append(cmds, cmd)
83+ // cmds = append(cmds, cmd)
158− cmd = a.commandDialog.Init()
84+ // cmd = a.commandDialog.Init()
159− cmds = append(cmds, cmd)
85+ // cmds = append(cmds, cmd)
160− cmd = a.modelDialog.Init()
86+ // cmd = a.modelDialog.Init()
161− cmds = append(cmds, cmd)
87+ // cmds = append(cmds, cmd)
162− cmd = a.initDialog.Init()
88+ // cmd = a.initDialog.Init()
163− cmds = append(cmds, cmd)
89+ // cmds = append(cmds, cmd)
164− cmd = a.filepicker.Init()
90+ // cmd = a.filepicker.Init()
165− cmds = append(cmds, cmd)
91+ // cmds = append(cmds, cmd)
166− cmd = a.themeDialog.Init()
92+ // cmd = a.themeDialog.Init()
167− cmds = append(cmds, cmd)
93+ // cmds = append(cmds, cmd)
168
94
169 // Check if we should show the init dialog
95 // Check if we should show the init dialog
170− cmds = append(cmds, func() tea.Msg {
96+ // cmds = append(cmds, func() tea.Msg {
171− shouldShow, err := config.ShouldShowInitDialog()
97+ // shouldShow, err := config.ShouldShowInitDialog()
172− if err != nil {
98+ // if err != nil {
173− return util.InfoMsg{
99+ // return util.InfoMsg{
174− Type: util.InfoTypeError,
100+ // Type: util.InfoTypeError,
175− Msg: "Failed to check init status: " + err.Error(),
101+ // Msg: "Failed to check init status: " + err.Error(),
176− }
102+ // }
177− }
103+ // }
178− return dialog.ShowInitDialogMsg{Show: shouldShow}
104+ // return dialog.ShowInitDialogMsg{Show: shouldShow}
179− })
105+ // })
180
106
107+ t := theme.CurrentTheme()
108+ cmds = append(cmds, tea.SetBackgroundColor(t.Background()))
181 return tea.Batch(cmds...)
109 return tea.Batch(cmds...)
182}
110}
183
111
184−func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
112+func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
185 var cmds []tea.Cmd
113 var cmds []tea.Cmd
186 var cmd tea.Cmd
114 var cmd tea.Cmd
115+
187 switch msg := msg.(type) {
116 switch msg := msg.(type) {
188 case tea.WindowSizeMsg:
117 case tea.WindowSizeMsg:
118+ return a, a.handleWindowResize(msg)
119+ // TODO: remove when refactor is done
189− msg.Height -= 1 // Make space for the status bar
120+ // msg.Height -= 1 // Make space for the status bar
190− a.width, a.height = msg.Width, msg.Height
121+ // a.width, a.height = msg.Width, msg.Height
191−
122+ //
192− s, _ := a.status.Update(msg)
123+ // s, _ := a.status.Update(msg)
193− a.status = s.(core.StatusCmp)
124+ // a.status = s.(core.StatusCmp)
194− updated, cmd := a.pages[a.currentPage].Update(msg)
125+ // updated, cmd := a.pages[a.currentPage].Update(msg)
195− a.pages[a.currentPage] = updated.(util.Model)
126+ // a.pages[a.currentPage] = updated.(util.Model)
196− cmds = append(cmds, cmd)
127+ // cmds = append(cmds, cmd)
197−
128+ //
198− prm, permCmd := a.permissions.Update(msg)
129+ // prm, permCmd := a.permissions.Update(msg)
199− a.permissions = prm.(dialog.PermissionDialogCmp)
130+ // a.permissions = prm.(dialog.PermissionDialogCmp)
200− cmds = append(cmds, permCmd)
131+ // cmds = append(cmds, permCmd)
201−
132+ //
202− help, helpCmd := a.help.Update(msg)
133+ // help, helpCmd := a.help.Update(msg)
203− a.help = help.(dialog.HelpCmp)
134+ // a.help = help.(dialog.HelpCmp)
204− cmds = append(cmds, helpCmd)
135+ // cmds = append(cmds, helpCmd)
205−
136+ //
206− session, sessionCmd := a.sessionDialog.Update(msg)
137+ // session, sessionCmd := a.sessionDialog.Update(msg)
207− a.sessionDialog = session.(dialog.SessionDialog)
138+ // a.sessionDialog = session.(dialog.SessionDialog)
208− cmds = append(cmds, sessionCmd)
139+ // cmds = append(cmds, sessionCmd)
209−
140+ //
210− command, commandCmd := a.commandDialog.Update(msg)
141+ // command, commandCmd := a.commandDialog.Update(msg)
211− a.commandDialog = command.(dialog.CommandDialog)
142+ // a.commandDialog = command.(dialog.CommandDialog)
212− cmds = append(cmds, commandCmd)
143+ // cmds = append(cmds, commandCmd)
213−
144+ //
214− filepicker, filepickerCmd := a.filepicker.Update(msg)
145+ // filepicker, filepickerCmd := a.filepicker.Update(msg)
215− a.filepicker = filepicker.(dialog.FilepickerCmp)
146+ // a.filepicker = filepicker.(dialog.FilepickerCmp)
216− cmds = append(cmds, filepickerCmd)
147+ // cmds = append(cmds, filepickerCmd)
217−
148+ //
218− a.initDialog.SetSize(msg.Width, msg.Height)
149+ // a.initDialog.SetSize(msg.Width, msg.Height)
219−
150+ //
220− if a.showMultiArgumentsDialog {
151+ // if a.showMultiArgumentsDialog {
221− a.multiArgumentsDialog.SetSize(msg.Width, msg.Height)
152+ // a.multiArgumentsDialog.SetSize(msg.Width, msg.Height)
222− args, argsCmd := a.multiArgumentsDialog.Update(msg)
153+ // args, argsCmd := a.multiArgumentsDialog.Update(msg)
223− a.multiArgumentsDialog = args.(dialog.MultiArgumentsDialogCmp)
154+ // a.multiArgumentsDialog = args.(dialog.MultiArgumentsDialogCmp)
224− cmds = append(cmds, argsCmd, a.multiArgumentsDialog.Init())
155+ // cmds = append(cmds, argsCmd, a.multiArgumentsDialog.Init())
225− }
156+ // }
157+ //
158+ // dialog, cmd := a.dialog.Update(msg)
159+ // a.dialog = dialog.(dialogs.DialogCmp)
160+ // cmds = append(cmds, cmd)
161+ //
162+ // return a, tea.Batch(cmds...)
163+
164+ // Dialog messages
165+ case dialogs.OpenDialogMsg, dialogs.CloseDialogMsg:
166+ u, dialogCmd := a.dialog.Update(msg)
167+ a.dialog = u.(dialogs.DialogCmp)
168+ return a, dialogCmd
169+
170+ // Page change messages
171+ case page.PageChangeMsg:
172+ return a, a.moveToPage(msg.ID)
226
173
227− return a, tea.Batch(cmds...)
228− // Status
174+ // Status Messages
229− case util.InfoMsg:
175+ case util.InfoMsg, util.ClearStatusMsg:
230 s, cmd := a.status.Update(msg)
176 s, cmd := a.status.Update(msg)
231 a.status = s.(core.StatusCmp)
177 a.status = s.(core.StatusCmp)
232 cmds = append(cmds, cmd)
178 cmds = append(cmds, cmd)
233 return a, tea.Batch(cmds...)
179 return a, tea.Batch(cmds...)
180+ // Logs
234 case pubsub.Event[logging.LogMessage]:
181 case pubsub.Event[logging.LogMessage]:
235− if msg.Payload.Persist {
182+ // Send to the status component
236− switch msg.Payload.Level {
237− case "error":
238− s, cmd := a.status.Update(util.InfoMsg{
239− Type: util.InfoTypeError,
240− Msg: msg.Payload.Message,
241− TTL: msg.Payload.PersistTime,
242− })
243− a.status = s.(core.StatusCmp)
244− cmds = append(cmds, cmd)
245− case "info":
246− s, cmd := a.status.Update(util.InfoMsg{
247− Type: util.InfoTypeInfo,
248− Msg: msg.Payload.Message,
249− TTL: msg.Payload.PersistTime,
250− })
251− a.status = s.(core.StatusCmp)
252− cmds = append(cmds, cmd)
253−
254− case "warn":
255− s, cmd := a.status.Update(util.InfoMsg{
256− Type: util.InfoTypeWarn,
257− Msg: msg.Payload.Message,
258− TTL: msg.Payload.PersistTime,
259− })
260−
261− a.status = s.(core.StatusCmp)
262− cmds = append(cmds, cmd)
263− default:
264− s, cmd := a.status.Update(util.InfoMsg{
265− Type: util.InfoTypeInfo,
266− Msg: msg.Payload.Message,
267− TTL: msg.Payload.PersistTime,
268− })
269− a.status = s.(core.StatusCmp)
270− cmds = append(cmds, cmd)
271− }
272− }
273− case util.ClearStatusMsg:
274− s, _ := a.status.Update(msg)
183+ s, cmd := a.status.Update(msg)
275 a.status = s.(core.StatusCmp)
184 a.status = s.(core.StatusCmp)
185+ cmds = append(cmds, cmd)
276
186
277− // Permission
187+ // If the current page is logs, update the logs view
278− case pubsub.Event[permission.PermissionRequest]:
188+ if a.currentPage == page.LogsPage {
279− a.showPermissions = true
189+ updated, cmd := a.pages[a.currentPage].Update(msg)
280− return a, a.permissions.SetPermissions(msg.Payload)
190+ a.pages[a.currentPage] = updated.(util.Model)
281− case dialog.PermissionResponseMsg:
191+ cmds = append(cmds, cmd)
282− var cmd tea.Cmd
283− switch msg.Action {
284− case dialog.PermissionAllow:
285− a.app.Permissions.Grant(msg.Permission)
286− case dialog.PermissionAllowForSession:
287− a.app.Permissions.GrantPersistant(msg.Permission)
288− case dialog.PermissionDeny:
289− a.app.Permissions.Deny(msg.Permission)
290 }
192 }
291− a.showPermissions = false
292− return a, cmd
193+ return a, tea.Batch(cmds...)
293−
294− case page.PageChangeMsg:
295− return a, a.moveToPage(msg.ID)
296−
297− case dialog.CloseQuitMsg:
298− a.showQuit = false
299− return a, nil
300−
301− case dialog.CloseSessionDialogMsg:
302− a.showSessionDialog = false
303− return a, nil
304−
305− case dialog.CloseCommandDialogMsg:
306− a.showCommandDialog = false
307− return a, nil
308−
309− case startCompactSessionMsg:
310− // Start compacting the current session
311− a.isCompacting = true
312− a.compactingMessage = "Starting summarization..."
313
194
195+ // // Permission
196+ // case pubsub.Event[permission.PermissionRequest]:
197+ // a.showPermissions = true
198+ // return a, a.permissions.SetPermissions(msg.Payload)
199+ // case dialog.PermissionResponseMsg:
200+ // var cmd tea.Cmd
201+ // switch msg.Action {
202+ // case dialog.PermissionAllow:
203+ // a.app.Permissions.Grant(msg.Permission)
204+ // case dialog.PermissionAllowForSession:
205+ // a.app.Permissions.GrantPersistant(msg.Permission)
206+ // case dialog.PermissionDeny:
207+ // a.app.Permissions.Deny(msg.Permission)
208+ // }
209+ // a.showPermissions = false
210+ // return a, cmd
211+ //
212+ // // Theme changed
213+ // case dialog.ThemeChangedMsg:
214+ // updated, cmd := a.pages[a.currentPage].Update(msg)
215+ // a.pages[a.currentPage] = updated.(util.Model)
216+ // a.showThemeDialog = false
217+ // return a, tea.Batch(cmd, util.ReportInfo("Theme changed to: "+msg.ThemeName))
218+ //
219+ // case dialog.CloseSessionDialogMsg:
220+ // a.showSessionDialog = false
221+ // return a, nil
222+ //
223+ // case dialog.CloseCommandDialogMsg:
224+ // a.showCommandDialog = false
225+ // return a, nil
226+ //
227+ // case startCompactSessionMsg:
228+ // // Start compacting the current session
229+ // a.isCompacting = true
230+ // a.compactingMessage = "Starting summarization..."
231+ //
314− if a.selectedSession.ID == "" {
232+ // if a.selectedSession.ID == "" {
315− a.isCompacting = false
233+ // a.isCompacting = false
316− return a, util.ReportWarn("No active session to summarize")
234+ // return a, util.ReportWarn("No active session to summarize")
235+ // }
236+ //
237+ // // Start the summarization process
238+ // return a, func() tea.Msg {
239+ // ctx := context.Background()
240+ // a.app.CoderAgent.Summarize(ctx, a.selectedSession.ID)
241+ // return nil
242+ // }
243+ //
244+ // case pubsub.Event[agent.AgentEvent]:
245+ // payload := msg.Payload
246+ // if payload.Error != nil {
247+ // a.isCompacting = false
248+ // return a, util.ReportError(payload.Error)
249+ // }
250+ //
251+ // a.compactingMessage = payload.Progress
252+ //
253+ // if payload.Done && payload.Type == agent.AgentEventTypeSummarize {
254+ // a.isCompacting = false
255+ // return a, util.ReportInfo("Session summarization complete")
256+ // } else if payload.Done && payload.Type == agent.AgentEventTypeResponse && a.selectedSession.ID != "" {
257+ // model := a.app.CoderAgent.Model()
258+ // contextWindow := model.ContextWindow
259+ // tokens := a.selectedSession.CompletionTokens + a.selectedSession.PromptTokens
260+ // if (tokens >= int64(float64(contextWindow)*0.95)) && config.Get().AutoCompact {
261+ // return a, util.CmdHandler(startCompactSessionMsg{})
262+ // }
263+ // }
264+ // // Continue listening for events
265+ // return a, nil
266+ //
267+ // case dialog.CloseThemeDialogMsg:
268+ // a.showThemeDialog = false
269+ // return a, nil
270+ //
271+ // case dialog.CloseModelDialogMsg:
272+ // a.showModelDialog = false
273+ // return a, nil
274+ //
275+ // case dialog.ModelSelectedMsg:
276+ // a.showModelDialog = false
277+ //
278+ // model, err := a.app.CoderAgent.Update(config.AgentCoder, msg.Model.ID)
279+ // if err != nil {
280+ // return a, util.ReportError(err)
281+ // }
282+ //
283+ // return a, util.ReportInfo(fmt.Sprintf("Model changed to %s", model.Name))
284+ //
285+ // case dialog.ShowInitDialogMsg:
286+ // a.showInitDialog = msg.Show
287+ // return a, nil
288+ //
289+ // case dialog.CloseInitDialogMsg:
290+ // a.showInitDialog = false
291+ // if msg.Initialize {
292+ // // Run the initialization command
293+ // for _, cmd := range a.commands {
294+ // if cmd.ID == "init" {
295+ // // Mark the project as initialized
296+ // if err := config.MarkProjectInitialized(); err != nil {
297+ // return a, util.ReportError(err)
298+ // }
299+ // return a, cmd.Handler(cmd)
300+ // }
301+ // }
302+ // } else {
303+ // // Mark the project as initialized without running the command
304+ // if err := config.MarkProjectInitialized(); err != nil {
305+ // return a, util.ReportError(err)
306+ // }
307+ // }
308+ // return a, nil
309+ //
310+ // case chat.SessionSelectedMsg:
311+ // a.selectedSession = msg
312+ // a.sessionDialog.SetSelectedSession(msg.ID)
313+ //
314+ // case pubsub.Event[session.Session]:
315+ // if msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == a.selectedSession.ID {
316+ // a.selectedSession = msg.Payload
317+ // }
318+ // case dialog.SessionSelectedMsg:
319+ // a.showSessionDialog = false
320+ // if a.currentPage == page.ChatPage {
321+ // return a, util.CmdHandler(chat.SessionSelectedMsg(msg.Session))
322+ // }
323+ // return a, nil
324+ //
325+ // case dialog.CommandSelectedMsg:
326+ // a.showCommandDialog = false
327+ // // Execute the command handler if available
328+ // if msg.Command.Handler != nil {
329+ // return a, msg.Command.Handler(msg.Command)
330+ // }
331+ // return a, util.ReportInfo("Command selected: " + msg.Command.Title)
332+ //
333+ // case dialog.ShowMultiArgumentsDialogMsg:
334+ // // Show multi-arguments dialog
335+ // a.multiArgumentsDialog = dialog.NewMultiArgumentsDialogCmp(msg.CommandID, msg.Content, msg.ArgNames)
336+ // a.showMultiArgumentsDialog = true
337+ // return a, a.multiArgumentsDialog.Init()
338+ //
339+ // case dialog.CloseMultiArgumentsDialogMsg:
340+ // // Close multi-arguments dialog
341+ // a.showMultiArgumentsDialog = false
342+ //
343+ // // If submitted, replace all named arguments and run the command
344+ // if msg.Submit {
345+ // content := msg.Content
346+ //
347+ // // Replace each named argument with its value
348+ // for name, value := range msg.Args {
349+ // placeholder := "$" + name
350+ // content = strings.ReplaceAll(content, placeholder, value)
351+ // }
352+ //
353+ // // Execute the command with arguments
354+ // return a, util.CmdHandler(dialog.CommandRunCustomMsg{
355+ // Content: content,
356+ // Args: msg.Args,
357+ // })
358+ // }
359+ // return a, nil
360+ //
361+ case tea.KeyPressMsg:
362+ return a, a.handleKeyPressMsg(msg)
363+ // if a.dialog.HasDialogs() {
364+ // u, dialogCmd := a.dialog.Update(msg)
365+ // a.dialog = u.(dialogs.DialogCmp)
366+ // return a, dialogCmd
367+ // }
368+ // // If multi-arguments dialog is open, let it handle the key press first
369+ // if a.showMultiArgumentsDialog {
370+ // args, cmd := a.multiArgumentsDialog.Update(msg)
371+ // a.multiArgumentsDialog = args.(dialog.MultiArgumentsDialogCmp)
372+ // return a, cmd
373+ // }
374+ //
375+ // switch {
376+ // case key.Matches(msg, keys.Quit):
377+ // // TODO: fix this after testing
378+ // // a.showQuit = !a.showQuit
379+ // // if a.showHelp {
380+ // // a.showHelp = false
381+ // // }
382+ // // if a.showSessionDialog {
383+ // // a.showSessionDialog = false
384+ // // }
385+ // // if a.showCommandDialog {
386+ // // a.showCommandDialog = false
387+ // // }
388+ // // if a.showFilepicker {
389+ // // a.showFilepicker = false
390+ // // a.filepicker.ToggleFilepicker(a.showFilepicker)
391+ // // }
392+ // // if a.showModelDialog {
393+ // // a.showModelDialog = false
394+ // // }
395+ // // if a.showMultiArgumentsDialog {
396+ // // a.showMultiArgumentsDialog = false
397+ // // }
398+ // return a, util.CmdHandler(dialogs.OpenDialogMsg{
399+ // Model: quit.NewQuitDialog(),
400+ // })
401+ // case key.Matches(msg, keys.SwitchSession):
402+ // if a.currentPage == page.ChatPage && !a.showPermissions && !a.showCommandDialog {
403+ // // Load sessions and show the dialog
404+ // sessions, err := a.app.Sessions.List(context.Background())
405+ // if err != nil {
406+ // return a, util.ReportError(err)
407+ // }
408+ // if len(sessions) == 0 {
409+ // return a, util.ReportWarn("No sessions available")
410+ // }
411+ // a.sessionDialog.SetSessions(sessions)
412+ // a.showSessionDialog = true
413+ // return a, nil
414+ // }
415+ // return a, nil
416+ // case key.Matches(msg, keys.Commands):
417+ // if a.currentPage == page.ChatPage && !a.showPermissions && !a.showSessionDialog && !a.showThemeDialog && !a.showFilepicker {
418+ // // Show commands dialog
419+ // if len(a.commands) == 0 {
420+ // return a, util.ReportWarn("No commands available")
421+ // }
422+ // a.commandDialog.SetCommands(a.commands)
423+ // a.showCommandDialog = true
424+ // return a, nil
425+ // }
426+ // return a, util.CmdHandler(dialogs.OpenDialogMsg{
427+ // Model: commands.NewCommandDialog(),
428+ // })
429+ // case key.Matches(msg, keys.Models):
430+ // if a.showModelDialog {
431+ // a.showModelDialog = false
432+ // return a, nil
433+ // }
434+ // if a.currentPage == page.ChatPage && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
435+ // a.showModelDialog = true
436+ // return a, nil
437+ // }
438+ // return a, nil
439+ // case key.Matches(msg, keys.SwitchTheme):
440+ // if !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
441+ // // Show theme switcher dialog
442+ // a.showThemeDialog = true
443+ // // Theme list is dynamically loaded by the dialog component
444+ // return a, a.themeDialog.Init()
445+ // }
446+ // return a, nil
447+ // case key.Matches(msg, returnKey) || key.Matches(msg):
448+ // if msg.String() == quitKey {
449+ // if a.currentPage == page.LogsPage {
450+ // return a, a.moveToPage(page.ChatPage)
451+ // }
452+ // } else if !a.filepicker.IsCWDFocused() {
453+ // if a.showHelp {
454+ // a.showHelp = !a.showHelp
455+ // return a, nil
456+ // }
457+ // if a.showInitDialog {
458+ // a.showInitDialog = false
459+ // // Mark the project as initialized without running the command
460+ // if err := config.MarkProjectInitialized(); err != nil {
461+ // return a, util.ReportError(err)
462+ // }
463+ // return a, nil
464+ // }
465+ // if a.showFilepicker {
466+ // a.showFilepicker = false
467+ // a.filepicker.ToggleFilepicker(a.showFilepicker)
468+ // return a, nil
469+ // }
470+ // if a.currentPage == page.LogsPage {
471+ // return a, a.moveToPage(page.ChatPage)
472+ // }
473+ // }
474+ // case key.Matches(msg, keys.Logs):
475+ // return a, a.moveToPage(page.LogsPage)
476+ // case key.Matches(msg, keys.Help):
477+ // a.showHelp = !a.showHelp
478+ // return a, nil
479+ // case key.Matches(msg, helpEsc):
480+ // if a.app.CoderAgent.IsBusy() {
481+ // a.showHelp = !a.showHelp
482+ // return a, nil
483+ // }
484+ // case key.Matches(msg, keys.Filepicker):
485+ // a.showFilepicker = !a.showFilepicker
486+ // a.filepicker.ToggleFilepicker(a.showFilepicker)
487+ // return a, nil
488+ // }
489+ // default:
490+ // u, dialogCmd := a.dialog.Update(msg)
491+ // a.dialog = u.(dialogs.DialogCmp)
492+ // cmds = append(cmds, dialogCmd)
493+ // f, filepickerCmd := a.filepicker.Update(msg)
494+ // a.filepicker = f.(dialog.FilepickerCmp)
495+ // cmds = append(cmds, filepickerCmd)
496+ // }
497+
498+ // if a.showFilepicker {
499+ // f, filepickerCmd := a.filepicker.Update(msg)
500+ // a.filepicker = f.(dialog.FilepickerCmp)
501+ // cmds = append(cmds, filepickerCmd)
502+ // // Only block key messages send all other messages down
503+ // if _, ok := msg.(tea.KeyPressMsg); ok {
504+ // return a, tea.Batch(cmds...)
505+ // }
506+ // }
507+ //
508+ // if a.showPermissions {
509+ // d, permissionsCmd := a.permissions.Update(msg)
510+ // a.permissions = d.(dialog.PermissionDialogCmp)
511+ // cmds = append(cmds, permissionsCmd)
512+ // // Only block key messages send all other messages down
513+ // if _, ok := msg.(tea.KeyPressMsg); ok {
514+ // return a, tea.Batch(cmds...)
515+ // }
516+ // }
517+ //
518+ // if a.showSessionDialog {
519+ // d, sessionCmd := a.sessionDialog.Update(msg)
520+ // a.sessionDialog = d.(dialog.SessionDialog)
521+ // cmds = append(cmds, sessionCmd)
522+ // // Only block key messages send all other messages down
523+ // if _, ok := msg.(tea.KeyPressMsg); ok {
524+ // return a, tea.Batch(cmds...)
525+ // }
526+ // }
527+ //
528+ // if a.showCommandDialog {
529+ // d, commandCmd := a.commandDialog.Update(msg)
530+ // a.commandDialog = d.(dialog.CommandDialog)
531+ // cmds = append(cmds, commandCmd)
532+ // // Only block key messages send all other messages down
533+ // if _, ok := msg.(tea.KeyPressMsg); ok {
534+ // return a, tea.Batch(cmds...)
535+ // }
536+ // }
537+ //
538+ // if a.showModelDialog {
539+ // d, modelCmd := a.modelDialog.Update(msg)
540+ // a.modelDialog = d.(dialog.ModelDialog)
541+ // cmds = append(cmds, modelCmd)
542+ // // Only block key messages send all other messages down
543+ // if _, ok := msg.(tea.KeyPressMsg); ok {
544+ // return a, tea.Batch(cmds...)
545+ // }
546+ // }
547+ //
548+ // if a.showInitDialog {
549+ // d, initCmd := a.initDialog.Update(msg)
550+ // a.initDialog = d.(dialog.InitDialogCmp)
551+ // cmds = append(cmds, initCmd)
552+ // // Only block key messages send all other messages down
553+ // if _, ok := msg.(tea.KeyPressMsg); ok {
554+ // return a, tea.Batch(cmds...)
555+ // }
556+ // }
557+ //
558+ // if a.showThemeDialog {
559+ // d, themeCmd := a.themeDialog.Update(msg)
560+ // a.themeDialog = d.(dialog.ThemeDialog)
561+ // cmds = append(cmds, themeCmd)
562+ // // Only block key messages send all other messages down
563+ // if _, ok := msg.(tea.KeyPressMsg); ok {
564+ // return a, tea.Batch(cmds...)
565+ // }
566+ }
567+ //
568+ s, _ := a.status.Update(msg)
569+ a.status = s.(core.StatusCmp)
570+ updated, cmd := a.pages[a.currentPage].Update(msg)
571+ a.pages[a.currentPage] = updated.(util.Model)
572+ cmds = append(cmds, cmd)
573+ return a, tea.Batch(cmds...)
317− }
574+}
318
575
319− // Start the summarization process
576+func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
320− return a, func() tea.Msg {
577+ var cmds []tea.Cmd
321− ctx := context.Background()
578+ msg.Height -= 1 // Make space for the status bar
322− a.app.CoderAgent.Summarize(ctx, a.selectedSession.ID)
579+ a.width, a.height = msg.Width, msg.Height
323− return nil
324− }
325
580
326− case pubsub.Event[agent.AgentEvent]:
581+ // Update status bar
327− payload := msg.Payload
582+ s, cmd := a.status.Update(msg)
328− if payload.Error != nil {
583+ a.status = s.(core.StatusCmp)
329− a.isCompacting = false
584+ cmds = append(cmds, cmd)
330− return a, util.ReportError(payload.Error)
331− }
332
585
333− a.compactingMessage = payload.Progress
586+ // Update the current page
334−
335− if payload.Done && payload.Type == agent.AgentEventTypeSummarize {
336− a.isCompacting = false
337− return a, util.ReportInfo("Session summarization complete")
338− } else if payload.Done && payload.Type == agent.AgentEventTypeResponse && a.selectedSession.ID != "" {
339− model := a.app.CoderAgent.Model()
340− contextWindow := model.ContextWindow
341− tokens := a.selectedSession.CompletionTokens + a.selectedSession.PromptTokens
342− if (tokens >= int64(float64(contextWindow)*0.95)) && config.Get().AutoCompact {
343− return a, util.CmdHandler(startCompactSessionMsg{})
344− }
345− }
346− // Continue listening for events
347− return a, nil
348−
349− case dialog.CloseThemeDialogMsg:
350− a.showThemeDialog = false
351− return a, nil
352−
353− case dialog.ThemeChangedMsg:
354− updated, cmd := a.pages[a.currentPage].Update(msg)
587+ updated, cmd := a.pages[a.currentPage].Update(msg)
355− a.pages[a.currentPage] = updated.(util.Model)
588+ a.pages[a.currentPage] = updated.(util.Model)
356− a.showThemeDialog = false
589+ cmds = append(cmds, cmd)
357− t := theme.CurrentTheme()
358− return a, tea.Batch(cmd, util.ReportInfo("Theme changed to: "+msg.ThemeName), tea.SetBackgroundColor(t.Background()))
359−
360− case dialog.CloseModelDialogMsg:
361− a.showModelDialog = false
362− return a, nil
363−
364− case dialog.ModelSelectedMsg:
365− a.showModelDialog = false
366−
367− model, err := a.app.CoderAgent.Update(config.AgentCoder, msg.Model.ID)
368− if err != nil {
369− return a, util.ReportError(err)
370− }
371
590
372− return a, util.ReportInfo(fmt.Sprintf("Model changed to %s", model.Name))
591+ // Update the dialogs
373−
592+ dialog, cmd := a.dialog.Update(msg)
374− case dialog.ShowInitDialogMsg:
593+ a.dialog = dialog.(dialogs.DialogCmp)
375− a.showInitDialog = msg.Show
594+ cmds = append(cmds, cmd)
376− return a, nil
377−
378− case dialog.CloseInitDialogMsg:
379− a.showInitDialog = false
380− if msg.Initialize {
381− // Run the initialization command
382− for _, cmd := range a.commands {
383− if cmd.ID == "init" {
384− // Mark the project as initialized
385− if err := config.MarkProjectInitialized(); err != nil {
386− return a, util.ReportError(err)
387− }
388− return a, cmd.Handler(cmd)
389− }
390− }
391− } else {
392− // Mark the project as initialized without running the command
393− if err := config.MarkProjectInitialized(); err != nil {
394− return a, util.ReportError(err)
395− }
396− }
397− return a, nil
398
595
399− case chat.SessionSelectedMsg:
596+ return tea.Batch(cmds...)
400− a.selectedSession = msg
597+}
401− a.sessionDialog.SetSelectedSession(msg.ID)
402
598
403− case pubsub.Event[session.Session]:
599+func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
404− if msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == a.selectedSession.ID {
600+ switch {
405− a.selectedSession = msg.Payload
601+ // dialogs
406− }
602+ case key.Matches(msg, a.keyMap.Quit):
407− case dialog.SessionSelectedMsg:
408− a.showSessionDialog = false
409− if a.currentPage == page.ChatPage {
410− return a, util.CmdHandler(chat.SessionSelectedMsg(msg.Session))
603+ return util.CmdHandler(dialogs.OpenDialogMsg{
604+ Model: quit.NewQuitDialog(),
411− }
605+ })
412− return a, nil
413
606
414− case dialog.CommandSelectedMsg:
607+ case key.Matches(msg, a.keyMap.Commands):
415− a.showCommandDialog = false
416− // Execute the command handler if available
417− if msg.Command.Handler != nil {
418− return a, msg.Command.Handler(msg.Command)
419− }
420− return a, util.ReportInfo("Command selected: " + msg.Command.Title)
421−
422− case dialog.ShowMultiArgumentsDialogMsg:
423− // Show multi-arguments dialog
424− a.multiArgumentsDialog = dialog.NewMultiArgumentsDialogCmp(msg.CommandID, msg.Content, msg.ArgNames)
425− a.showMultiArgumentsDialog = true
426− return a, a.multiArgumentsDialog.Init()
427−
428− case dialog.CloseMultiArgumentsDialogMsg:
429− // Close multi-arguments dialog
430− a.showMultiArgumentsDialog = false
431−
432− // If submitted, replace all named arguments and run the command
433− if msg.Submit {
434− content := msg.Content
435−
436− // Replace each named argument with its value
437− for name, value := range msg.Args {
438− placeholder := "$" + name
439− content = strings.ReplaceAll(content, placeholder, value)
440− }
441−
442− // Execute the command with arguments
443− return a, util.CmdHandler(dialog.CommandRunCustomMsg{
608+ return util.CmdHandler(dialogs.OpenDialogMsg{
444− Content: content,
609+ Model: commands.NewCommandDialog(),
445− Args: msg.Args,
446− })
447− }
610+ })
448− return a, nil
449
611
450− case tea.KeyPressMsg:
612+ // Page navigation
451− // If multi-arguments dialog is open, let it handle the key press first
613+ case key.Matches(msg, a.keyMap.Logs):
452− if a.showMultiArgumentsDialog {
614+ return a.moveToPage(page.LogsPage)
453− args, cmd := a.multiArgumentsDialog.Update(msg)
454− a.multiArgumentsDialog = args.(dialog.MultiArgumentsDialogCmp)
455− return a, cmd
456− }
457
615
458− switch {
459− case key.Matches(msg, keys.Quit):
460− a.showQuit = !a.showQuit
461− if a.showHelp {
462− a.showHelp = false
463− }
464− if a.showSessionDialog {
465− a.showSessionDialog = false
466− }
467− if a.showCommandDialog {
468− a.showCommandDialog = false
469− }
470− if a.showFilepicker {
471− a.showFilepicker = false
472− a.filepicker.ToggleFilepicker(a.showFilepicker)
473− }
474− if a.showModelDialog {
475− a.showModelDialog = false
476− }
477− if a.showMultiArgumentsDialog {
478− a.showMultiArgumentsDialog = false
479− }
480− return a, nil
481− case key.Matches(msg, keys.SwitchSession):
482− if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showCommandDialog {
483− // Load sessions and show the dialog
484− sessions, err := a.app.Sessions.List(context.Background())
485− if err != nil {
486− return a, util.ReportError(err)
487− }
488− if len(sessions) == 0 {
489− return a, util.ReportWarn("No sessions available")
490− }
491− a.sessionDialog.SetSessions(sessions)
492− a.showSessionDialog = true
493− return a, nil
494− }
495− return a, nil
496− case key.Matches(msg, keys.Commands):
497− if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showThemeDialog && !a.showFilepicker {
498− // Show commands dialog
499− if len(a.commands) == 0 {
500− return a, util.ReportWarn("No commands available")
501− }
502− a.commandDialog.SetCommands(a.commands)
503− a.showCommandDialog = true
504− return a, nil
505− }
506− return a, nil
507− case key.Matches(msg, keys.Models):
508− if a.showModelDialog {
509− a.showModelDialog = false
510− return a, nil
511− }
512− if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
513− a.showModelDialog = true
514− return a, nil
515− }
516− return a, nil
517− case key.Matches(msg, keys.SwitchTheme):
518− if !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
519− // Show theme switcher dialog
520− a.showThemeDialog = true
521− // Theme list is dynamically loaded by the dialog component
522− return a, a.themeDialog.Init()
523− }
524− return a, nil
525− case key.Matches(msg, returnKey) || key.Matches(msg):
526− if msg.String() == quitKey {
527− if a.currentPage == page.LogsPage {
528− return a, a.moveToPage(page.ChatPage)
529− }
530− } else if !a.filepicker.IsCWDFocused() {
531− if a.showQuit {
532− a.showQuit = !a.showQuit
533− return a, nil
534− }
535− if a.showHelp {
536− a.showHelp = !a.showHelp
537− return a, nil
538− }
539− if a.showInitDialog {
540− a.showInitDialog = false
541− // Mark the project as initialized without running the command
542− if err := config.MarkProjectInitialized(); err != nil {
543− return a, util.ReportError(err)
544− }
545− return a, nil
546− }
547− if a.showFilepicker {
548− a.showFilepicker = false
549− a.filepicker.ToggleFilepicker(a.showFilepicker)
550− return a, nil
551− }
552− if a.currentPage == page.LogsPage {
553− return a, a.moveToPage(page.ChatPage)
554− }
555− }
556− case key.Matches(msg, keys.Logs):
557− return a, a.moveToPage(page.LogsPage)
558− case key.Matches(msg, keys.Help):
559− if a.showQuit {
560− return a, nil
561− }
562− a.showHelp = !a.showHelp
563− return a, nil
564− case key.Matches(msg, helpEsc):
565− if a.app.CoderAgent.IsBusy() {
566− if a.showQuit {
567− return a, nil
568− }
569− a.showHelp = !a.showHelp
570− return a, nil
571− }
572− case key.Matches(msg, keys.Filepicker):
573− a.showFilepicker = !a.showFilepicker
574− a.filepicker.ToggleFilepicker(a.showFilepicker)
575− return a, nil
576− }
577 default:
616 default:
578− f, filepickerCmd := a.filepicker.Update(msg)
617+ if a.dialog.HasDialogs() {
579− a.filepicker = f.(dialog.FilepickerCmp)
618+ u, dialogCmd := a.dialog.Update(msg)
580− cmds = append(cmds, filepickerCmd)
619+ a.dialog = u.(dialogs.DialogCmp)
581− }
620+ return dialogCmd
582−
621+ } else {
583− if a.showFilepicker {
622+ updated, cmd := a.pages[a.currentPage].Update(msg)
584− f, filepickerCmd := a.filepicker.Update(msg)
623+ a.pages[a.currentPage] = updated.(util.Model)
585− a.filepicker = f.(dialog.FilepickerCmp)
586− cmds = append(cmds, filepickerCmd)
587− // Only block key messages send all other messages down
588− if _, ok := msg.(tea.KeyPressMsg); ok {
589− return a, tea.Batch(cmds...)
590− }
591− }
592−
593− if a.showQuit {
594− q, quitCmd := a.quit.Update(msg)
595− a.quit = q.(dialog.QuitDialog)
596− cmds = append(cmds, quitCmd)
597− // Only block key messages send all other messages down
598− if _, ok := msg.(tea.KeyPressMsg); ok {
599− return a, tea.Batch(cmds...)
624+ return cmd
600− }
601− }
602− if a.showPermissions {
603− d, permissionsCmd := a.permissions.Update(msg)
604− a.permissions = d.(dialog.PermissionDialogCmp)
605− cmds = append(cmds, permissionsCmd)
606− // Only block key messages send all other messages down
607− if _, ok := msg.(tea.KeyPressMsg); ok {
608− return a, tea.Batch(cmds...)
609− }
610− }
611−
612− if a.showSessionDialog {
613− d, sessionCmd := a.sessionDialog.Update(msg)
614− a.sessionDialog = d.(dialog.SessionDialog)
615− cmds = append(cmds, sessionCmd)
616− // Only block key messages send all other messages down
617− if _, ok := msg.(tea.KeyPressMsg); ok {
618− return a, tea.Batch(cmds...)
619− }
620− }
621−
622− if a.showCommandDialog {
623− d, commandCmd := a.commandDialog.Update(msg)
624− a.commandDialog = d.(dialog.CommandDialog)
625− cmds = append(cmds, commandCmd)
626− // Only block key messages send all other messages down
627− if _, ok := msg.(tea.KeyPressMsg); ok {
628− return a, tea.Batch(cmds...)
629− }
630− }
631−
632− if a.showModelDialog {
633− d, modelCmd := a.modelDialog.Update(msg)
634− a.modelDialog = d.(dialog.ModelDialog)
635− cmds = append(cmds, modelCmd)
636− // Only block key messages send all other messages down
637− if _, ok := msg.(tea.KeyPressMsg); ok {
638− return a, tea.Batch(cmds...)
639− }
640− }
641−
642− if a.showInitDialog {
643− d, initCmd := a.initDialog.Update(msg)
644− a.initDialog = d.(dialog.InitDialogCmp)
645− cmds = append(cmds, initCmd)
646− // Only block key messages send all other messages down
647− if _, ok := msg.(tea.KeyPressMsg); ok {
648− return a, tea.Batch(cmds...)
649− }
650− }
651−
652− if a.showThemeDialog {
653− d, themeCmd := a.themeDialog.Update(msg)
654− a.themeDialog = d.(dialog.ThemeDialog)
655− cmds = append(cmds, themeCmd)
656− // Only block key messages send all other messages down
657− if _, ok := msg.(tea.KeyPressMsg); ok {
658− return a, tea.Batch(cmds...)
659 }
625 }
660 }
626 }
661−
662− s, _ := a.status.Update(msg)
663− a.status = s.(core.StatusCmp)
664− updated, cmd := a.pages[a.currentPage].Update(msg)
665− a.pages[a.currentPage] = updated.(util.Model)
666− cmds = append(cmds, cmd)
667− return a, tea.Batch(cmds...)
668}
627}
669
628
670// RegisterCommand adds a command to the command dialog
629// RegisterCommand adds a command to the command dialog
671−func (a *appModel) RegisterCommand(cmd dialog.Command) {
630+// func (a *appModel) RegisterCommand(cmd dialog.Command) {
672− a.commands = append(a.commands, cmd)
631+// a.commands = append(a.commands, cmd)
673−}
632+// }
674
633
675func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
634func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
676 if a.app.CoderAgent.IsBusy() {
635 if a.app.CoderAgent.IsBusy() {
@@ -694,265 +653,276 @@ func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
694 return tea.Batch(cmds...)
653 return tea.Batch(cmds...)
695}
654}
696
655
697−func (a appModel) View() string {
656+func (a *appModel) View() tea.View {
657+ pageView := a.pages[a.currentPage].View()
698 components := []string{
658 components := []string{
699− a.pages[a.currentPage].View(),
659+ pageView.String(),
700 }
660 }
701− components = append(components, a.status.View())
661+ components = append(components, a.status.View().String())
702
662
703 appView := lipgloss.JoinVertical(lipgloss.Top, components...)
663 appView := lipgloss.JoinVertical(lipgloss.Top, components...)
704
664
705− if a.showPermissions {
665+ // if a.showPermissions {
706− overlay := a.permissions.View()
666+ // overlay := a.permissions.View().String()
707− row := lipgloss.Height(appView) / 2
667+ // row := lipgloss.Height(appView) / 2
708− row -= lipgloss.Height(overlay) / 2
668+ // row -= lipgloss.Height(overlay) / 2
709− col := lipgloss.Width(appView) / 2
669+ // col := lipgloss.Width(appView) / 2
710− col -= lipgloss.Width(overlay) / 2
670+ // col -= lipgloss.Width(overlay) / 2
711− appView = layout.PlaceOverlay(
671+ // appView = layout.PlaceOverlay(
712− col,
672+ // col,
713− row,
673+ // row,
714− overlay,
674+ // overlay,
715− appView,
675+ // appView,
716− true,
676+ // true,
717− )
677+ // )
718− }
678+ // }
719−
679+ //
720− if a.showFilepicker {
680+ // if a.showFilepicker {
721− overlay := a.filepicker.View()
681+ // overlay := a.filepicker.View().String()
722− row := lipgloss.Height(appView) / 2
682+ // row := lipgloss.Height(appView) / 2
723− row -= lipgloss.Height(overlay) / 2
683+ // row -= lipgloss.Height(overlay) / 2
724− col := lipgloss.Width(appView) / 2
684+ // col := lipgloss.Width(appView) / 2
725− col -= lipgloss.Width(overlay) / 2
685+ // col -= lipgloss.Width(overlay) / 2
726− appView = layout.PlaceOverlay(
686+ // appView = layout.PlaceOverlay(
727− col,
687+ // col,
728− row,
688+ // row,
729− overlay,
689+ // overlay,
730− appView,
690+ // appView,
731− true,
691+ // true,
732− )
692+ // )
733− }
693+ // }
734−
694+ //
735− // Show compacting status overlay
695+ // // Show compacting status overlay
736− if a.isCompacting {
696+ // if a.isCompacting {
737− t := theme.CurrentTheme()
697+ // t := theme.CurrentTheme()
738− style := lipgloss.NewStyle().
698+ // style := lipgloss.NewStyle().
739− Border(lipgloss.RoundedBorder()).
699+ // Border(lipgloss.RoundedBorder()).
740− BorderForeground(t.BorderFocused()).
700+ // BorderForeground(t.BorderFocused()).
741− BorderBackground(t.Background()).
701+ // BorderBackground(t.Background()).
742− Padding(1, 2).
702+ // Padding(1, 2).
743− Background(t.Background()).
703+ // Background(t.Background()).
744− Foreground(t.Text())
704+ // Foreground(t.Text())
745−
705+ //
746− overlay := style.Render("Summarizing\n" + a.compactingMessage)
706+ // overlay := style.Render("Summarizing\n" + a.compactingMessage)
747− row := lipgloss.Height(appView) / 2
707+ // row := lipgloss.Height(appView) / 2
748− row -= lipgloss.Height(overlay) / 2
708+ // row -= lipgloss.Height(overlay) / 2
749− col := lipgloss.Width(appView) / 2
709+ // col := lipgloss.Width(appView) / 2
750− col -= lipgloss.Width(overlay) / 2
710+ // col -= lipgloss.Width(overlay) / 2
751− appView = layout.PlaceOverlay(
711+ // appView = layout.PlaceOverlay(
752− col,
712+ // col,
753− row,
713+ // row,
754− overlay,
714+ // overlay,
755− appView,
715+ // appView,
756− true,
716+ // true,
757− )
717+ // )
758− }
718+ // }
759−
719+ //
760− if a.showHelp {
720+ // if a.showHelp {
761− bindings := layout.KeyMapToSlice(keys)
721+ // bindings := layout.KeyMapToSlice(a.keymap)
762− if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
722+ // if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
763− bindings = append(bindings, p.BindingKeys()...)
723+ // bindings = append(bindings, p.BindingKeys()...)
764− }
724+ // }
765− if a.showPermissions {
725+ // if a.showPermissions {
766− bindings = append(bindings, a.permissions.BindingKeys()...)
726+ // bindings = append(bindings, a.permissions.BindingKeys()...)
767− }
727+ // }
768− if a.currentPage == page.LogsPage {
728+ // if a.currentPage == page.LogsPage {
769− bindings = append(bindings, logsKeyReturnKey)
729+ // // bindings = append(bindings, logsKeyReturnKey)
770− }
730+ // }
771− if !a.app.CoderAgent.IsBusy() {
731+ // if !a.app.CoderAgent.IsBusy() {
772− bindings = append(bindings, helpEsc)
732+ // // bindings = append(bindings, helpEsc)
773− }
733+ // }
734+ //
774− a.help.SetBindings(bindings)
735+ // a.help.SetBindings(bindings)
775−
736+ //
776− overlay := a.help.View()
737+ // overlay := a.help.View().String()
777− row := lipgloss.Height(appView) / 2
778− row -= lipgloss.Height(overlay) / 2
779− col := lipgloss.Width(appView) / 2
780− col -= lipgloss.Width(overlay) / 2
781− appView = layout.PlaceOverlay(
782− col,
783− row,
784− overlay,
785− appView,
786− true,
787− )
788− }
789−
790− if a.showQuit {
791− overlay := a.quit.View()
792− row := lipgloss.Height(appView) / 2
738+ // row := lipgloss.Height(appView) / 2
793− row -= lipgloss.Height(overlay) / 2
739+ // row -= lipgloss.Height(overlay) / 2
794− col := lipgloss.Width(appView) / 2
740+ // col := lipgloss.Width(appView) / 2
795− col -= lipgloss.Width(overlay) / 2
741+ // col -= lipgloss.Width(overlay) / 2
796− appView = layout.PlaceOverlay(
742+ // appView = layout.PlaceOverlay(
797− col,
743+ // col,
798− row,
744+ // row,
799− overlay,
745+ // overlay,
800− appView,
746+ // appView,
801− true,
747+ // true,
802− )
748+ // )
803− }
749+ // }
804−
750+ //
805− if a.showSessionDialog {
751+ // if a.showSessionDialog {
806− overlay := a.sessionDialog.View()
752+ // overlay := a.sessionDialog.View().String()
807− row := lipgloss.Height(appView) / 2
753+ // row := lipgloss.Height(appView) / 2
808− row -= lipgloss.Height(overlay) / 2
754+ // row -= lipgloss.Height(overlay) / 2
809− col := lipgloss.Width(appView) / 2
755+ // col := lipgloss.Width(appView) / 2
810− col -= lipgloss.Width(overlay) / 2
756+ // col -= lipgloss.Width(overlay) / 2
811− appView = layout.PlaceOverlay(
757+ // appView = layout.PlaceOverlay(
812− col,
758+ // col,
813− row,
759+ // row,
814− overlay,
760+ // overlay,
815− appView,
761+ // appView,
816− true,
762+ // true,
817− )
763+ // )
818− }
764+ // }
819−
765+ //
820− if a.showModelDialog {
766+ // if a.showModelDialog {
821− overlay := a.modelDialog.View()
767+ // overlay := a.modelDialog.View().String()
822− row := lipgloss.Height(appView) / 2
768+ // row := lipgloss.Height(appView) / 2
823− row -= lipgloss.Height(overlay) / 2
769+ // row -= lipgloss.Height(overlay) / 2
824− col := lipgloss.Width(appView) / 2
770+ // col := lipgloss.Width(appView) / 2
825− col -= lipgloss.Width(overlay) / 2
771+ // col -= lipgloss.Width(overlay) / 2
826− appView = layout.PlaceOverlay(
772+ // appView = layout.PlaceOverlay(
827− col,
773+ // col,
828− row,
774+ // row,
829− overlay,
775+ // overlay,
830− appView,
776+ // appView,
831− true,
777+ // true,
832− )
778+ // )
833− }
779+ // }
834−
780+ //
835− if a.showCommandDialog {
781+ // if a.showCommandDialog {
836− overlay := a.commandDialog.View()
782+ // overlay := a.commandDialog.View().String()
837− row := lipgloss.Height(appView) / 2
783+ // row := lipgloss.Height(appView) / 2
838− row -= lipgloss.Height(overlay) / 2
784+ // row -= lipgloss.Height(overlay) / 2
839− col := lipgloss.Width(appView) / 2
785+ // col := lipgloss.Width(appView) / 2
840− col -= lipgloss.Width(overlay) / 2
786+ // col -= lipgloss.Width(overlay) / 2
841− appView = layout.PlaceOverlay(
787+ // appView = layout.PlaceOverlay(
842− col,
788+ // col,
843− row,
789+ // row,
844− overlay,
790+ // overlay,
845− appView,
791+ // appView,
846− true,
792+ // true,
847− )
793+ // )
848− }
794+ // }
849−
795+ //
850− if a.showInitDialog {
796+ // if a.showInitDialog {
851− overlay := a.initDialog.View()
797+ // overlay := a.initDialog.View()
852− appView = layout.PlaceOverlay(
798+ // appView = layout.PlaceOverlay(
853− a.width/2-lipgloss.Width(overlay)/2,
799+ // a.width/2-lipgloss.Width(overlay)/2,
854− a.height/2-lipgloss.Height(overlay)/2,
800+ // a.height/2-lipgloss.Height(overlay)/2,
855− overlay,
801+ // overlay,
856− appView,
802+ // appView,
857− true,
803+ // true,
858− )
804+ // )
859− }
805+ // }
860−
806+ //
861− if a.showThemeDialog {
807+ // if a.showThemeDialog {
862− overlay := a.themeDialog.View()
808+ // overlay := a.themeDialog.View().String()
809+ // row := lipgloss.Height(appView) / 2
810+ // row -= lipgloss.Height(overlay) / 2
811+ // col := lipgloss.Width(appView) / 2
812+ // col -= lipgloss.Width(overlay) / 2
813+ // appView = layout.PlaceOverlay(
814+ // col,
815+ // row,
816+ // overlay,
817+ // appView,
818+ // true,
819+ // )
820+ // }
821+ //
822+ // if a.showMultiArgumentsDialog {
823+ // overlay := a.multiArgumentsDialog.View()
863− row := lipgloss.Height(appView) / 2
824+ // row := lipgloss.Height(appView) / 2
864− row -= lipgloss.Height(overlay) / 2
825+ // row -= lipgloss.Height(overlay) / 2
865− col := lipgloss.Width(appView) / 2
826+ // col := lipgloss.Width(appView) / 2
866− col -= lipgloss.Width(overlay) / 2
827+ // col -= lipgloss.Width(overlay) / 2
867− appView = layout.PlaceOverlay(
828+ // appView = layout.PlaceOverlay(
868− col,
829+ // col,
869− row,
830+ // row,
870− overlay,
831+ // overlay,
832+ // appView,
833+ // true,
834+ // )
835+ // }
836+ t := theme.CurrentTheme()
837+ if a.dialog.HasDialogs() {
838+ layers := append(
839+ []*lipgloss.Layer{
871− appView,
840+ lipgloss.NewLayer(appView),
872− true,
841+ },
842+ a.dialog.GetLayers()...,
873 )
843 )
874− }
844+ canvas := lipgloss.NewCanvas(
875−
845+ layers...,
876− if a.showMultiArgumentsDialog {
877− overlay := a.multiArgumentsDialog.View()
878− row := lipgloss.Height(appView) / 2
879− row -= lipgloss.Height(overlay) / 2
880− col := lipgloss.Width(appView) / 2
881− col -= lipgloss.Width(overlay) / 2
882− appView = layout.PlaceOverlay(
883− col,
884− row,
885− overlay,
886− appView,
887− true,
888 )
846 )
847+ view := tea.NewView(canvas.Render())
848+ activeView := a.dialog.ActiveView()
849+ view.SetBackgroundColor(t.Background())
850+ view.SetCursor(activeView.Cursor())
851+ return view
889 }
852 }
890
853
891− return appView
854+ view := tea.NewView(appView)
855+ view.SetCursor(pageView.Cursor())
856+ view.SetBackgroundColor(t.Background())
857+ return view
892}
858}
893
859
894func New(app *app.App) tea.Model {
860func New(app *app.App) tea.Model {
895 startPage := page.ChatPage
861 startPage := page.ChatPage
896 model := &appModel{
862 model := &appModel{
897− currentPage: startPage,
863+ currentPage: startPage,
864+ app: app,
865+ status: core.NewStatusCmp(app.LSPClients),
898− loadedPages: make(map[page.PageID]bool),
866+ loadedPages: make(map[page.PageID]bool),
899− status: core.NewStatusCmp(app.LSPClients),
867+ keyMap: DefaultKeyMap(),
868+
900− help: dialog.NewHelpCmp(),
869+ // help: dialog.NewHelpCmp(),
901− quit: dialog.NewQuitCmp(),
902− sessionDialog: dialog.NewSessionDialogCmp(),
870+ // sessionDialog: dialog.NewSessionDialogCmp(),
903− commandDialog: dialog.NewCommandDialogCmp(),
871+ // commandDialog: dialog.NewCommandDialogCmp(),
904− modelDialog: dialog.NewModelDialogCmp(),
872+ // modelDialog: dialog.NewModelDialogCmp(),
905− permissions: dialog.NewPermissionDialogCmp(),
873+ // permissions: dialog.NewPermissionDialogCmp(),
906− initDialog: dialog.NewInitDialogCmp(),
874+ // initDialog: dialog.NewInitDialogCmp(),
907− themeDialog: dialog.NewThemeDialogCmp(),
875+ // themeDialog: dialog.NewThemeDialogCmp(),
908− app: app,
909− commands: []dialog.Command{},
876+ // commands: []dialog.Command{},
910 pages: map[page.PageID]util.Model{
877 pages: map[page.PageID]util.Model{
911 page.ChatPage: page.NewChatPage(app),
878 page.ChatPage: page.NewChatPage(app),
912 page.LogsPage: page.NewLogsPage(),
879 page.LogsPage: page.NewLogsPage(),
913 },
880 },
914− filepicker: dialog.NewFilepickerCmp(app),
881+ // filepicker: dialog.NewFilepickerCmp(app),
915− }
916
882
917− model.RegisterCommand(dialog.Command{
883+ // New dialog
918− ID: "init",
884+ dialog: dialogs.NewDialogCmp(),
919− Title: "Initialize Project",
920− Description: "Create/Update the OpenCode.md memory file",
921− Handler: func(cmd dialog.Command) tea.Cmd {
922− prompt := `Please analyze this codebase and create a OpenCode.md file containing:
923−1. Build/lint/test commands - especially for running a single test
924−2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
925−
926−The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long.
927−If there's already a opencode.md, improve it.
928−If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.`
929− return tea.Batch(
930− util.CmdHandler(chat.SendMsg{
931− Text: prompt,
932− }),
933− )
934− },
935− })
936−
937− model.RegisterCommand(dialog.Command{
938− ID: "compact",
939− Title: "Compact Session",
940− Description: "Summarize the current session and create a new one with the summary",
941− Handler: func(cmd dialog.Command) tea.Cmd {
942− return func() tea.Msg {
943− return startCompactSessionMsg{}
944− }
945− },
946− })
947− // Load custom commands
948− customCommands, err := dialog.LoadCustomCommands()
949− if err != nil {
950− logging.Warn("Failed to load custom commands", "error", err)
951− } else {
952− for _, cmd := range customCommands {
953− model.RegisterCommand(cmd)
954− }
955 }
885 }
956
886
887+ // model.RegisterCommand(dialog.Command{
888+ // ID: "init",
889+ // Title: "Initialize Project",
890+ // Description: "Create/Update the OpenCode.md memory file",
891+ // Handler: func(cmd dialog.Command) tea.Cmd {
892+ // prompt := `Please analyze this codebase and create a OpenCode.md file containing:
893+ // 1. Build/lint/test commands - especially for running a single test
894+ // 2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
895+ //
896+ // The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long.
897+ // If there's already a opencode.md, improve it.
898+ // If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.`
899+ // return tea.Batch(
900+ // util.CmdHandler(chat.SendMsg{
901+ // Text: prompt,
902+ // }),
903+ // )
904+ // },
905+ // })
906+ //
907+ // model.RegisterCommand(dialog.Command{
908+ // ID: "compact",
909+ // Title: "Compact Session",
910+ // Description: "Summarize the current session and create a new one with the summary",
911+ // Handler: func(cmd dialog.Command) tea.Cmd {
912+ // return func() tea.Msg {
913+ // return startCompactSessionMsg{}
914+ // }
915+ // },
916+ // })
917+ // // Load custom commands
918+ // customCommands, err := dialog.LoadCustomCommands()
919+ // if err != nil {
920+ // logging.Warn("Failed to load custom commands", "error", err)
921+ // } else {
922+ // for _, cmd := range customCommands {
923+ // model.RegisterCommand(cmd)
924+ // }
925+ // }
926+
957 return model
927 return model
958}
928}
internal/tui/util/util.go+1−1
@@ -8,7 +8,7 @@ import (
8
8
9type Model interface {
9type Model interface {
10 tea.Model
10 tea.Model
11− tea.ViewModel
11+ tea.Viewable
12}
12}
13
13
14func CmdHandler(msg tea.Msg) tea.Cmd {
14func CmdHandler(msg tea.Msg) tea.Cmd {