on main help sign in
wip dialogs
commitf1544004b32d2b6a640ee0a339ddb6220d418a84 authorKujtim Hoxha <kujtimii.h@gmail.com> dateTue 27 May 2025, 14:57 (1 year ago) parenta23e1a3
40 files changed+1926−1094
cspell.json+1 go.mod+9−9 go.sum+18−18 internal/tui/components/anim/anim.go+3−3 internal/tui/components/chat/editor.go+21−6 internal/tui/components/chat/list.go+3−3 internal/tui/components/chat/messages/messages.go+5−5 internal/tui/components/chat/messages/renderer.go+1−1 internal/tui/components/chat/messages/tool.go+5−5 internal/tui/components/chat/sidebar.go+19−17 internal/tui/components/core/list/list.go+9−4 internal/tui/components/core/status.go+38−2 internal/tui/components/dialog/arguments.go+7−6 internal/tui/components/dialog/commands.go+10−8 internal/tui/components/dialog/complete.go+12−10 internal/tui/components/dialog/filepicker.go+4−2 internal/tui/components/dialog/help.go+14−12 internal/tui/components/dialog/models.go+9−7 internal/tui/components/dialog/permission.go+2−2 internal/tui/components/dialog/session.go+16−12 internal/tui/components/dialog/theme.go+17−13 internal/tui/components/dialogs/commands/arguments.go+16 internal/tui/components/dialogs/commands/commands.go+135 internal/tui/components/dialogs/commands/item.go+55 internal/tui/components/dialogs/commands/loader.go+204 internal/tui/components/dialogs/dialogs.go+164 internal/tui/components/dialogs/keys.go+37 internal/tui/components/dialogs/quit/keys.go+59 internal/tui/components/dialog/quit.go → internal/tui/components/dialogs/quit/quit.go+48−58 internal/tui/components/logs/details.go+2−2 internal/tui/components/logs/table.go+2−2 internal/tui/components/util/simple-list.go+11−7 internal/tui/keys.go+75 internal/tui/layout/container.go+17−2 internal/tui/layout/layout.go+4 internal/tui/layout/split.go+33−16 internal/tui/page/chat.go+10−5 internal/tui/page/logs.go+9−5 internal/tui/tui.go+821−851 internal/tui/util/util.go+1−1
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 (
1111 github.com/aymanbagabas/go-udiff v0.2.0
1212 github.com/bmatcuk/doublestar/v4 v4.8.1
1313 github.com/catppuccin/go v0.3.0
14− github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1
15− github.com/charmbracelet/bubbletea/v2 v2.0.0-beta1
16− github.com/charmbracelet/glamour/v2 v2.0.0-20250513163904-eeeced3bb3c6
17− github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250513162854-28902d027c40
18− github.com/charmbracelet/x/ansi v0.9.2
14+ github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318
15+ github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250526132317-434f93986a5c
16+ github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
17+ github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c
18+ github.com/charmbracelet/x/ansi v0.9.3-0.20250516160309-24eee56f89fa
1919 github.com/fsnotify/fsnotify v1.8.0
2020 github.com/go-logfmt/logfmt v0.6.0
2121 github.com/google/uuid v1.6.0
@@ -57,12 +57,12 @@ require (
5757 github.com/aws/smithy-go v1.20.3 // indirect
5858 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
5959 github.com/aymerick/douceur v0.2.0 // indirect
60− github.com/charmbracelet/colorprofile v0.3.0 // indirect
61− github.com/charmbracelet/x/cellbuf v0.0.14-0.20250326144200-0875329e71da // indirect
60+ github.com/charmbracelet/colorprofile v0.3.1 // indirect
61+ github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa // indirect
6262 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
6464 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
6666 github.com/davecgh/go-spew v1.1.1 // indirect
6767 github.com/disintegration/imaging v1.6.2
6868 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
6868github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
6969github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
7070github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
71−github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 h1:swACzss0FjnyPz1enfX56GKkLiuKg5FlyVmOLIlU2kE=
72−github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
73−github.com/charmbracelet/bubbletea/v2 v2.0.0-beta1 h1:yaxFt97mvofGY7bYZn8U/aSVoamXGE3O4AEvWhshUDI=
74−github.com/charmbracelet/bubbletea/v2 v2.0.0-beta1/go.mod h1:qbcZLI5z8R49v9xBdU5V5Dh5D2uccx8wSwBqxQyErqc=
75−github.com/charmbracelet/colorprofile v0.3.0 h1:KtLh9uuu1RCt+Hml4s6Hz+kB1PfV3wi++1h5ia65yKQ=
76−github.com/charmbracelet/colorprofile v0.3.0/go.mod h1:oHJ340RS2nmG1zRGPmhJKJ/jf4FPNNk0P39/wBPA1G0=
77−github.com/charmbracelet/glamour/v2 v2.0.0-20250513163904-eeeced3bb3c6 h1:AKhOV8dSRU3KpqMgpGME9JU7ouumB2S6hMmD6PRJeTc=
78−github.com/charmbracelet/glamour/v2 v2.0.0-20250513163904-eeeced3bb3c6/go.mod h1:7xBAUTCSADx9mHG0uBf4NDoVpYxMzIQ2j/NMLGdFsFM=
79−github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250513162854-28902d027c40 h1:SxOUomYAVo5zh+6WCH1bGshlAnSKP0ZeovI0FHAl9kg=
80−github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250513162854-28902d027c40/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc=
81−github.com/charmbracelet/x/ansi v0.9.2 h1:92AGsQmNTRMzuzHEYfCdjQeUzTrgE1vfO5/7fEVoXdY=
82−github.com/charmbracelet/x/ansi v0.9.2/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
83−github.com/charmbracelet/x/cellbuf v0.0.14-0.20250326144200-0875329e71da h1:8MGKD5WBtuzfXglq0CnyzVSwGojv57X+H46OL9OUyRA=
84−github.com/charmbracelet/x/cellbuf v0.0.14-0.20250326144200-0875329e71da/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
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.0.20250526131538-b3f0c9e42318/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
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-beta.3.0.20250526132317-434f93986a5c/go.mod h1:sXuGtrlVJo43r1fVGBM06E7PPb16oBl8rDRr6YgQOck=
75+github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
76+github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
77+github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe h1:i6ce4CcAlPpTj2ER69m1DBeLZ3RRcHnKExuwhKa3GfY=
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.20250523195325-2d1af06b557c h1:177KMz8zHRlEZJsWzafbKYh6OdjgvTspoH+UjaxgIXY=
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.3-0.20250516160309-24eee56f89fa h1:JU05TLAB6nOEL46bxHDV/+e8umBX32ODsGbVkc7o7bk=
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.20250516160309-24eee56f89fa h1:lphz0Z3rsiOtMYiz8axkT24i9yFiueDhJbzyNUADmME=
84+github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk=
8585github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw=
8686github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
8787github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
8888github.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=
90−github.com/charmbracelet/x/input v0.3.4/go.mod h1:JI8RcvdZWQIhn09VzeK3hdp4lTz7+yhiEdpEQtZN+2c=
89+github.com/charmbracelet/x/input v0.3.5-0.20250509021451-13796e822d86 h1:BxAEmOBIDajkgao3EsbBxKQCYvgYPGdT62WASLvtf4Y=
90+github.com/charmbracelet/x/input v0.3.5-0.20250509021451-13796e822d86/go.mod h1:62Rp/6EtTxoeJDSdtpA3tJp3y3ZRpsiekBSje+K8htA=
9191github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
9292github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
93−github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw=
94−github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s=
93+github.com/charmbracelet/x/windows v0.2.1 h1:3x7vnbpQrjpuq/4L+I4gNsG5htYoCiA5oe9hLjAij5I=
94+github.com/charmbracelet/x/windows v0.2.1/go.mod h1:ptZp16h40gDYqs5TSawSVW+yiLB13j4kSMA0lSCHL0M=
9595github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
9696github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
9797github.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) {
239239}
240240
241241// View renders the animation.
242−func (a anim) View() string {
242+func (a anim) View() tea.View {
243243 t := theme.CurrentTheme()
244244 var b strings.Builder
245245
@@ -259,10 +259,10 @@ func (a anim) View() string {
259259 textStyle.Render(string(c.currentValue)),
260260 )
261261 }
262− return b.String() + textStyle.Render(a.ellipsis.View())
262+ return tea.NewView(b.String() + textStyle.Render(a.ellipsis.View()))
263263 }
264264
265− return b.String()
265+ return tea.NewView(b.String())
266266}
267267
268268func makeGradientRamp(length int) []color.Color {
internal/tui/components/chat/editor.go+21−6
@@ -26,6 +26,7 @@ import (
2626type editorCmp struct {
2727 width int
2828 height int
29+ x, y int
2930 app *app.App
3031 session session.Session
3132 textarea textarea.Model
@@ -116,7 +117,7 @@ func (m *editorCmp) openEditor() tea.Cmd {
116117}
117118
118119func (m *editorCmp) Init() tea.Cmd {
119− return textarea.Blink
120+ return nil
120121}
121122
122123func (m *editorCmp) send() tea.Cmd {
@@ -212,7 +213,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
212213 return m, cmd
213214}
214215
215−func (m *editorCmp) View() string {
216+func (m *editorCmp) View() tea.View {
216217 t := theme.CurrentTheme()
217218
218219 // Style the prompt with theme colors
@@ -221,16 +222,23 @@ func (m *editorCmp) View() string {
221222 Bold(true).
222223 Foreground(t.Primary())
223224
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
224228 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
226232 }
227233 m.textarea.SetHeight(m.height - 1)
228− return lipgloss.JoinVertical(lipgloss.Top,
234+ view := tea.NewView(lipgloss.JoinVertical(lipgloss.Top,
229235 m.attachmentsContent(),
230236 lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"),
231237 m.textarea.View(),
232238 ),
233− )
239+ ))
240+ view.SetCursor(cursor)
241+ return view
234242}
235243
236244func (m *editorCmp) SetSize(width, height int) tea.Cmd {
@@ -275,6 +283,12 @@ func (m *editorCmp) BindingKeys() []key.Binding {
275283 return bindings
276284}
277285
286+func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
287+ m.x = x
288+ m.y = y
289+ return nil
290+}
291+
278292func CreateTextArea(existing *textarea.Model) textarea.Model {
279293 t := theme.CurrentTheme()
280294 bgColor := t.Background()
@@ -297,11 +311,12 @@ func CreateTextArea(existing *textarea.Model) textarea.Model {
297311
298312 s.Focused = f
299313 s.Blurred = b
300− ta.Styles = s
314+ ta.SetStyles(s)
301315
302316 ta.Prompt = " "
303317 ta.ShowLineNumbers = false
304318 ta.CharLimit = -1
319+ ta.SetVirtualCursor(false)
305320
306321 if existing != nil {
307322 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) {
104104}
105105
106106// View renders the message list or an initial screen if empty.
107−func (m *messageListCmp) View() string {
107+func (m *messageListCmp) View() tea.View {
108108 if len(m.listCmp.Items()) == 0 {
109− return initialScreen()
109+ return tea.NewView(initialScreen())
110110 }
111− return lipgloss.JoinVertical(lipgloss.Left, m.listCmp.View())
111+ return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, m.listCmp.View().String()))
112112}
113113
114114// 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) {
9494
9595// View renders the message component based on its current state.
9696// Returns different views for spinning, user, and assistant messages.
97−func (m *messageCmp) View() string {
97+func (m *messageCmp) View() tea.View {
9898 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()))
100100 }
101101 if m.message.ID != "" {
102102 // this is a user or assistant message
103103 switch m.message.Role {
104104 case message.User:
105− return m.renderUserMessage()
105+ return tea.NewView(m.renderUserMessage())
106106 default:
107− return m.renderAssistantMessage()
107+ return tea.NewView(m.renderAssistantMessage())
108108 }
109109 }
110− return "Unknown Message"
110+ return tea.NewView(m.style().Render("No message content"))
111111}
112112
113113// 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 {
507507 }
508508 if v.result.ToolCallID == "" {
509509 v.spinning = true
510− parts = append(parts, v.anim.View())
510+ parts = append(parts, v.anim.View().String())
511511 } else {
512512 v.spinning = false
513513 }
internal/tui/components/chat/messages/tool.go+5−5
@@ -134,22 +134,22 @@ func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
134134
135135// View renders the tool call component based on its current state.
136136// Shows either a pending animation or the tool-specific rendered result.
137−func (m *toolCallCmp) View() string {
137+func (m *toolCallCmp) View() tea.View {
138138 box := m.style()
139139
140140 if !m.call.Finished && !m.cancelled {
141141 if m.isNested {
142− return box.Render(m.renderPending())
142+ return tea.NewView(box.Render(m.renderPending()))
143143 }
144− return box.PaddingLeft(1).Render(m.renderPending())
144+ return tea.NewView(box.PaddingLeft(1).Render(m.renderPending()))
145145 }
146146
147147 r := registry.lookup(m.call.Name)
148148
149149 if m.isNested {
150− return box.Render(r.Render(m))
150+ return tea.NewView(box.Render(r.Render(m)))
151151 }
152− return box.PaddingLeft(1).Render(r.Render(m))
152+ return tea.NewView(box.PaddingLeft(1).Render(r.Render(m)))
153153}
154154
155155// 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) {
8282 return m, nil
8383}
8484
85−func (m *sidebarCmp) View() string {
85+func (m *sidebarCmp) View() tea.View {
8686 baseStyle := styles.BaseStyle()
8787
88− return baseStyle.
89− Width(m.width).
90− PaddingLeft(4).
91− PaddingRight(2).
92− Height(m.height - 1).
93− Render(
94− lipgloss.JoinVertical(
95− lipgloss.Top,
96− header(),
97− " ",
98− m.sessionSection(),
99− " ",
100− lspsConfigured(),
101− " ",
102− m.modifiedFiles(),
88+ return tea.NewView(
89+ baseStyle.
90+ Width(m.width).
91+ PaddingLeft(4).
92+ PaddingRight(2).
93+ Height(m.height - 1).
94+ Render(
95+ lipgloss.JoinVertical(
96+ lipgloss.Top,
97+ header(),
98+ " ",
99+ m.sessionSection(),
100+ " ",
101+ lspsConfigured(),
102+ " ",
103+ m.modifiedFiles(),
104+ ),
103105 ),
104− )
106+ )
105107}
106108
107109func (m *sidebarCmp) sessionSection() string {
internal/tui/components/core/list/list.go+9−4
@@ -269,14 +269,19 @@ func (m *model) scrollUp(amount int) {
269269// View renders the list to a string for display.
270270// Returns empty string if the list has no dimensions.
271271// Triggers re-rendering if needed before returning content.
272−func (m *model) View() string {
272+func (m *model) View() tea.View {
273273 if m.viewState.height == 0 || m.viewState.width == 0 {
274− return ""
274+ return tea.NewView("") // No content to display
275275 }
276276 if m.renderState.needsRerender {
277277 m.renderVisible()
278278 }
279− return lipgloss.NewStyle().Padding(m.padding...).Height(m.viewState.height).Render(m.viewState.content)
279+ return tea.NewView(
280+ lipgloss.NewStyle().
281+ Padding(m.padding...).
282+ Height(m.viewState.height).
283+ Render(m.viewState.content),
284+ )
280285}
281286
282287// Items returns a copy of all items in the list.
@@ -642,7 +647,7 @@ func (m *model) rerenderItem(inx int) {
642647
643648// getItemLines converts an item to its rendered lines, including any gap spacing.
644649func (m *model) getItemLines(item util.Model) []string {
645− itemLines := strings.Split(item.View(), "\n")
650+ itemLines := strings.Split(item.View().String(), "\n")
646651 if m.gapSize > 0 {
647652 gap := make([]string, m.gapSize)
648653 itemLines = append(itemLines, gap...)
internal/tui/components/core/status.go+38−2
@@ -9,6 +9,7 @@ import (
99 "github.com/charmbracelet/lipgloss/v2"
1010 "github.com/opencode-ai/opencode/internal/config"
1111 "github.com/opencode-ai/opencode/internal/llm/models"
12+ "github.com/opencode-ai/opencode/internal/logging"
1213 "github.com/opencode-ai/opencode/internal/lsp"
1314 "github.com/opencode-ai/opencode/internal/lsp/protocol"
1415 "github.com/opencode-ai/opencode/internal/pubsub"
@@ -47,6 +48,8 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
4748 case tea.WindowSizeMsg:
4849 m.width = msg.Width
4950 return m, nil
51+
52+ // Handle sesson messages
5053 case chat.SessionSelectedMsg:
5154 m.session = msg
5255 case chat.SessionClearedMsg:
@@ -57,6 +60,8 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
5760 m.session = msg.Payload
5861 }
5962 }
63+
64+ // Handle status info
6065 case util.InfoMsg:
6166 m.info = msg
6267 ttl := msg.TTL
@@ -66,6 +71,37 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
6671 return m, m.clearMessageCmd(ttl)
6772 case util.ClearStatusMsg:
6873 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+ }
69105 }
70106 return m, nil
71107}
@@ -116,7 +152,7 @@ func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
116152 return fmt.Sprintf("Context: %s, Cost: %s", formattedTokens, formattedCost)
117153}
118154
119−func (m statusCmp) View() string {
155+func (m statusCmp) View() tea.View {
120156 t := theme.CurrentTheme()
121157 modelID := config.Get().Agents[config.AgentCoder].Model
122158 model := models.SupportedModels[modelID]
@@ -176,7 +212,7 @@ func (m statusCmp) View() string {
176212
177213 status += diagnostics
178214 status += m.model()
179− return status
215+ return tea.NewView(status)
180216}
181217
182218func (m *statusCmp) projectDiagnostics() string {
internal/tui/components/dialog/arguments.go+7−6
@@ -73,12 +73,13 @@ func NewMultiArgumentsDialogCmp(commandID, content string, argNames []string) Mu
7373 ti.Placeholder = fmt.Sprintf("Enter value for %s...", name)
7474 ti.SetWidth(40)
7575 ti.Prompt = ""
76− ti.Styles.Focused.Placeholder = ti.Styles.Focused.Placeholder.Background(t.Background())
77− ti.Styles.Blurred.Placeholder = ti.Styles.Blurred.Placeholder.Background(t.Background())
78− ti.Styles.Focused.Suggestion = ti.Styles.Focused.Suggestion.Background(t.Background()).Foreground(t.Primary())
79− ti.Styles.Blurred.Suggestion = ti.Styles.Blurred.Suggestion.Background(t.Background())
80− ti.Styles.Focused.Text = ti.Styles.Focused.Text.Background(t.Background()).Foreground(t.Primary())
81− ti.Styles.Blurred.Text = ti.Styles.Blurred.Text.Background(t.Background())
76+ styles := ti.Styles()
77+ styles.Focused.Placeholder = styles.Focused.Placeholder.Background(t.Background())
78+ styles.Blurred.Placeholder = styles.Blurred.Placeholder.Background(t.Background())
79+ styles.Focused.Suggestion = styles.Focused.Suggestion.Background(t.Background()).Foreground(t.Primary())
80+ styles.Blurred.Suggestion = styles.Blurred.Suggestion.Background(t.Background())
81+ styles.Focused.Text = styles.Focused.Text.Background(t.Background()).Foreground(t.Primary())
82+ styles.Blurred.Text = styles.Blurred.Text.Background(t.Background())
8283
8384 // Only focus the first input initially
8485 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) {
114114 return c, tea.Batch(cmds...)
115115}
116116
117−func (c *commandDialogCmp) View() string {
117+func (c *commandDialogCmp) View() tea.View {
118118 t := theme.CurrentTheme()
119119 baseStyle := styles.BaseStyle()
120120
@@ -146,16 +146,18 @@ func (c *commandDialogCmp) View() string {
146146 lipgloss.Left,
147147 title,
148148 baseStyle.Width(maxWidth).Render(""),
149− baseStyle.Width(maxWidth).Render(c.listView.View()),
149+ baseStyle.Width(maxWidth).Render(c.listView.View().String()),
150150 baseStyle.Width(maxWidth).Render(""),
151151 )
152152
153− return baseStyle.Padding(1, 2).
154− Border(lipgloss.RoundedBorder()).
155− BorderBackground(t.Background()).
156− BorderForeground(t.TextMuted()).
157− Width(lipgloss.Width(content) + 4).
158− Render(content)
153+ return tea.NewView(
154+ baseStyle.Padding(1, 2).
155+ Border(lipgloss.RoundedBorder()).
156+ BorderBackground(t.Background()).
157+ BorderForeground(t.TextMuted()).
158+ Width(lipgloss.Width(content) + 4).
159+ Render(content),
160+ )
159161}
160162
161163func (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) {
202202 return c, tea.Batch(cmds...)
203203}
204204
205−func (c *completionDialogCmp) View() string {
205+func (c *completionDialogCmp) View() tea.View {
206206 t := theme.CurrentTheme()
207207 baseStyle := styles.BaseStyle()
208208
@@ -219,15 +219,17 @@ func (c *completionDialogCmp) View() string {
219219
220220 c.listView.SetMaxWidth(maxWidth)
221221
222− return baseStyle.Padding(0, 0).
223− Border(lipgloss.NormalBorder()).
224− BorderBottom(false).
225− BorderRight(false).
226− BorderLeft(false).
227− BorderBackground(t.Background()).
228− BorderForeground(t.TextMuted()).
229− Width(c.width).
230− Render(c.listView.View())
222+ return tea.NewView(
223+ baseStyle.Padding(0, 0).
224+ Border(lipgloss.NormalBorder()).
225+ BorderBottom(false).
226+ BorderRight(false).
227+ BorderLeft(false).
228+ BorderBackground(t.Background()).
229+ BorderForeground(t.TextMuted()).
230+ Width(c.width).
231+ Render(c.listView.View().String()),
232+ )
231233}
232234
233235func (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) {
258258 return f, util.CmdHandler(AttachmentAddedMsg{attachment})
259259}
260260
261−func (f *filepickerCmp) View() string {
261+func (f *filepickerCmp) View() tea.View {
262262 t := theme.CurrentTheme()
263263 const maxVisibleDirs = 20
264264 const maxWidth = 80
@@ -349,7 +349,9 @@ func (f *filepickerCmp) View() string {
349349 BorderForeground(t.TextMuted()).
350350 Width(lipgloss.Width(content) + 4)
351351
352− return lipgloss.JoinHorizontal(lipgloss.Center, contentStyle.Render(content), viewportstyle)
352+ return tea.NewView(
353+ lipgloss.JoinHorizontal(lipgloss.Center, contentStyle.Render(content), viewportstyle),
354+ )
353355}
354356
355357type FilepickerCmp interface {
internal/tui/components/dialog/help.go+14−12
@@ -166,7 +166,7 @@ func (h *helpCmp) render() string {
166166 return content
167167}
168168
169−func (h *helpCmp) View() string {
169+func (h *helpCmp) View() tea.View {
170170 t := theme.CurrentTheme()
171171 baseStyle := styles.BaseStyle()
172172
@@ -177,18 +177,20 @@ func (h *helpCmp) View() string {
177177 Foreground(t.Primary()).
178178 Render("Keyboard Shortcuts")
179179
180− return baseStyle.Padding(1).
181− Border(lipgloss.RoundedBorder()).
182− BorderForeground(t.TextMuted()).
183− Width(h.width).
184− BorderBackground(t.Background()).
185− Render(
186− lipgloss.JoinVertical(lipgloss.Center,
187− header,
188− baseStyle.Render(strings.Repeat(" ", lipgloss.Width(header))),
189− content,
180+ return tea.NewView(
181+ baseStyle.Padding(1).
182+ Border(lipgloss.RoundedBorder()).
183+ BorderForeground(t.TextMuted()).
184+ Width(h.width).
185+ BorderBackground(t.Background()).
186+ Render(
187+ lipgloss.JoinVertical(lipgloss.Center,
188+ header,
189+ baseStyle.Render(strings.Repeat(" ", lipgloss.Width(header))),
190+ content,
191+ ),
190192 ),
191− )
193+ )
192194}
193195
194196type HelpCmp interface {
internal/tui/components/dialog/models.go+9−7
@@ -185,7 +185,7 @@ func (m *modelDialogCmp) switchProvider(offset int) {
185185 m.setupModelsForProvider(m.provider)
186186}
187187
188−func (m *modelDialogCmp) View() string {
188+func (m *modelDialogCmp) View() tea.View {
189189 t := theme.CurrentTheme()
190190 baseStyle := styles.BaseStyle()
191191
@@ -220,12 +220,14 @@ func (m *modelDialogCmp) View() string {
220220 scrollIndicator,
221221 )
222222
223− return baseStyle.Padding(1, 2).
224− Border(lipgloss.RoundedBorder()).
225− BorderBackground(t.Background()).
226− BorderForeground(t.TextMuted()).
227− Width(lipgloss.Width(content) + 4).
228− Render(content)
223+ return tea.NewView(
224+ baseStyle.Padding(1, 2).
225+ Border(lipgloss.RoundedBorder()).
226+ BorderBackground(t.Background()).
227+ BorderForeground(t.TextMuted()).
228+ Width(lipgloss.Width(content) + 4).
229+ Render(content),
230+ )
229231}
230232
231233func (m *modelDialogCmp) getScrollIndicators(maxWidth int) string {
internal/tui/components/dialog/permission.go+2−2
@@ -437,8 +437,8 @@ func (p *permissionDialogCmp) render() string {
437437 )
438438}
439439
440−func (p *permissionDialogCmp) View() string {
441− return p.render()
440+func (p *permissionDialogCmp) View() tea.View {
441+ return tea.NewView(p.render())
442442}
443443
444444func (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) {
105105 return s, nil
106106}
107107
108−func (s *sessionDialogCmp) View() string {
108+func (s *sessionDialogCmp) View() tea.View {
109109 t := theme.CurrentTheme()
110110 baseStyle := styles.BaseStyle()
111111
112112 if len(s.sessions) == 0 {
113− return baseStyle.Padding(1, 2).
114− Border(lipgloss.RoundedBorder()).
115− BorderBackground(t.Background()).
116− BorderForeground(t.TextMuted()).
117− Width(40).
118− Render("No sessions available")
113+ return tea.NewView(
114+ baseStyle.Padding(1, 2).
115+ Border(lipgloss.RoundedBorder()).
116+ BorderBackground(t.Background()).
117+ BorderForeground(t.TextMuted()).
118+ Width(40).
119+ Render("No sessions available"),
120+ )
119121 }
120122
121123 // Calculate max width needed for session titles
@@ -177,11 +179,13 @@ func (s *sessionDialogCmp) View() string {
177179 baseStyle.Width(maxWidth).Render(""),
178180 )
179181
180− return baseStyle.Padding(1, 2).
181− Border(lipgloss.RoundedBorder()).
182− BorderBackground(t.Background()).
183− BorderForeground(t.TextMuted()).
184− Render(content)
182+ return tea.NewView(
183+ baseStyle.Padding(1, 2).
184+ Border(lipgloss.RoundedBorder()).
185+ BorderBackground(t.Background()).
186+ BorderForeground(t.TextMuted()).
187+ Render(content),
188+ )
185189}
186190
187191func (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) {
122122 return t, nil
123123}
124124
125−func (t *themeDialogCmp) View() string {
125+func (t *themeDialogCmp) View() tea.View {
126126 currentTheme := theme.CurrentTheme()
127127 baseStyle := styles.BaseStyle()
128128
129129 if len(t.themes) == 0 {
130− return baseStyle.Padding(1, 2).
131− Border(lipgloss.RoundedBorder()).
132− BorderBackground(currentTheme.Background()).
133− BorderForeground(currentTheme.TextMuted()).
134− Width(40).
135− Render("No themes available")
130+ return tea.NewView(
131+ baseStyle.Padding(1, 2).
132+ Border(lipgloss.RoundedBorder()).
133+ BorderBackground(currentTheme.Background()).
134+ BorderForeground(currentTheme.TextMuted()).
135+ Width(40).
136+ Render("No themes available"),
137+ )
136138 }
137139
138140 // Calculate max width needed for theme names
@@ -175,12 +177,14 @@ func (t *themeDialogCmp) View() string {
175177 baseStyle.Width(maxWidth).Render(""),
176178 )
177179
178− return baseStyle.Padding(1, 2).
179− Border(lipgloss.RoundedBorder()).
180− BorderBackground(currentTheme.Background()).
181− BorderForeground(currentTheme.TextMuted()).
182− Width(lipgloss.Width(content) + 4).
183− Render(content)
180+ return tea.NewView(
181+ baseStyle.Padding(1, 2).
182+ Border(lipgloss.RoundedBorder()).
183+ BorderBackground(currentTheme.Background()).
184+ BorderForeground(currentTheme.TextMuted()).
185+ Width(lipgloss.Width(content) + 4).
186+ Render(content),
187+ )
184188}
185189
186190func (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
22
33import (
4− "strings"
5−
64 "github.com/charmbracelet/bubbles/v2/key"
75 tea "github.com/charmbracelet/bubbletea/v2"
86 "github.com/charmbracelet/lipgloss/v2"
7+ "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
98 "github.com/opencode-ai/opencode/internal/tui/layout"
109 "github.com/opencode-ai/opencode/internal/tui/styles"
1110 "github.com/opencode-ai/opencode/internal/tui/theme"
1211 "github.com/opencode-ai/opencode/internal/tui/util"
1312)
1413
15−const question = "Are you sure you want to quit?"
16−
17−type CloseQuitMsg struct{}
14+const (
15+ question = "Are you sure you want to quit?"
16+ id dialogs.DialogID = "quit"
17+)
1818
19+// QuitDialog represents a confirmation dialog for quitting the application.
1920type QuitDialog interface {
20− util.Model
21+ dialogs.DialogModel
2122 layout.Bindings
2223}
2324
2425type quitDialogCmp struct {
25− selectedNo bool
26−}
26+ wWidth int
27+ wHeight int
2728
28−type helpMapping struct {
29− LeftRight key.Binding
30− EnterSpace key.Binding
31− Yes key.Binding
32− No key.Binding
33− Tab key.Binding
29+ selectedNo bool // true if "No" button is selected
30+ keymap KeyMap
3431}
3532
36−var helpKeys = helpMapping{
37− LeftRight: key.NewBinding(
38− key.WithKeys("left", "right"),
39− key.WithHelp("←/→", "switch options"),
40− ),
41− EnterSpace: key.NewBinding(
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− ),
33+// NewQuitDialog creates a new quit confirmation dialog.
34+func NewQuitDialog() QuitDialog {
35+ return &quitDialogCmp{
36+ selectedNo: true, // Default to "No" for safety
37+ keymap: DefaultKeymap(),
38+ }
5739}
5840
5941func (q *quitDialogCmp) Init() tea.Cmd {
6042 return nil
6143}
6244
45+// Update handles keyboard input for the quit dialog.
6346func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
6447 switch msg := msg.(type) {
48+ case tea.WindowSizeMsg:
49+ q.wWidth = msg.Width
50+ q.wHeight = msg.Height
6551 case tea.KeyPressMsg:
6652 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):
6854 q.selectedNo = !q.selectedNo
6955 return q, nil
70− case key.Matches(msg, helpKeys.EnterSpace):
56+ case key.Matches(msg, q.keymap.EnterSpace):
7157 if !q.selectedNo {
7258 return q, tea.Quit
7359 }
74− return q, util.CmdHandler(CloseQuitMsg{})
75− case key.Matches(msg, helpKeys.Yes):
60+ return q, util.CmdHandler(dialogs.CloseDialogMsg{})
61+ case key.Matches(msg, q.keymap.Yes):
7662 return q, tea.Quit
77− case key.Matches(msg, helpKeys.No):
78− return q, util.CmdHandler(CloseQuitMsg{})
63+ case key.Matches(msg, q.keymap.No):
64+ return q, util.CmdHandler(dialogs.CloseDialogMsg{})
7965 }
8066 }
8167 return q, nil
8268}
8369
84−func (q *quitDialogCmp) View() string {
70+// View renders the quit dialog with Yes/No buttons.
71+func (q *quitDialogCmp) View() tea.View {
8572 t := theme.CurrentTheme()
8673 baseStyle := styles.BaseStyle()
8774
@@ -100,13 +87,9 @@ func (q *quitDialogCmp) View() string {
10087 yesButton := yesStyle.Padding(0, 1).Render("Yes")
10188 noButton := noStyle.Padding(0, 1).Render("No")
10289
103− buttons := lipgloss.JoinHorizontal(lipgloss.Left, yesButton, spacerStyle.Render(" "), noButton)
104−
105− width := lipgloss.Width(question)
106− remainingWidth := width - lipgloss.Width(buttons)
107− if remainingWidth > 0 {
108− buttons = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + buttons
109− }
90+ buttons := baseStyle.Width(lipgloss.Width(question)).Align(lipgloss.Right).Render(
91+ lipgloss.JoinHorizontal(lipgloss.Center, yesButton, spacerStyle.Render(" "), noButton),
92+ )
11093
11194 content := baseStyle.Render(
11295 lipgloss.JoinVertical(
@@ -123,17 +106,24 @@ func (q *quitDialogCmp) View() string {
123106 BorderBackground(t.Background()).
124107 BorderForeground(t.TextMuted())
125108
126− return quitDialogStyle.
127− Width(lipgloss.Width(content) + quitDialogStyle.GetHorizontalFrameSize()).
128− Render(content)
109+ return tea.NewView(
110+ quitDialogStyle.Render(content),
111+ )
129112}
130113
131114func (q *quitDialogCmp) BindingKeys() []key.Binding {
132− return layout.KeyMapToSlice(helpKeys)
115+ return layout.KeyMapToSlice(q.keymap)
133116}
134117
135−func NewQuitCmp() QuitDialog {
136− return &quitDialogCmp{
137− selectedNo: true,
138− }
118+func (q *quitDialogCmp) Position() (int, int) {
119+ row := q.wHeight / 2
120+ row -= 7 / 2
121+ col := q.wWidth / 2
122+ col -= (lipgloss.Width(question) + 4) / 2
123+
124+ return row, col
125+}
126+
127+func (q *quitDialogCmp) ID() dialogs.DialogID {
128+ return id
139129}
internal/tui/components/logs/details.go+2−2
@@ -114,8 +114,8 @@ func getLevelStyle(level string) lipgloss.Style {
114114 }
115115}
116116
117−func (i *detailCmp) View() string {
118− return i.viewport.View()
117+func (i *detailCmp) View() tea.View {
118+ return tea.NewView(i.viewport.View())
119119}
120120
121121func (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) {
6060 return i, tea.Batch(cmds...)
6161}
6262
63−func (i *tableCmp) View() string {
63+func (i *tableCmp) View() tea.View {
6464 t := theme.CurrentTheme()
6565 defaultStyles := table.DefaultStyles()
6666 defaultStyles.Selected = defaultStyles.Selected.Foreground(t.Primary())
6767 i.table.SetStyles(defaultStyles)
68− return i.table.View()
68+ return tea.NewView(i.table.View())
6969}
7070
7171func (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) {
110110 c.maxWidth = width
111111}
112112
113−func (c *simpleListCmp[T]) View() string {
113+func (c *simpleListCmp[T]) View() tea.View {
114114 t := theme.CurrentTheme()
115115 baseStyle := styles.BaseStyle()
116116
@@ -120,11 +120,13 @@ func (c *simpleListCmp[T]) View() string {
120120 startIdx := 0
121121
122122 if len(items) <= 0 {
123− return baseStyle.
124− Background(t.Background()).
125− Padding(0, 1).
126− Width(maxWidth).
127− Render(c.fallbackMsg)
123+ return tea.NewView(
124+ baseStyle.
125+ Background(t.Background()).
126+ Padding(0, 1).
127+ Width(maxWidth).
128+ Render(c.fallbackMsg),
129+ )
128130 }
129131
130132 if len(items) > maxVisibleItems {
@@ -146,7 +148,9 @@ func (c *simpleListCmp[T]) View() string {
146148 listItems = append(listItems, title)
147149 }
148150
149− return lipgloss.JoinVertical(lipgloss.Left, listItems...)
151+ return tea.NewView(
152+ lipgloss.JoinVertical(lipgloss.Left, listItems...),
153+ )
150154}
151155
152156func 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 {
1212 util.Model
1313 Sizeable
1414 Bindings
15+ Positionable
1516}
1617type container struct {
1718 width int
1819 height int
1920
21+ x, y int
22+
2023 content util.Model
2124
2225 // Style options
@@ -42,7 +45,7 @@ func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
4245 return c, cmd
4346}
4447
45−func (c *container) View() string {
48+func (c *container) View() tea.View {
4649 t := theme.CurrentTheme()
4750 style := lipgloss.NewStyle()
4851 width := c.width
@@ -76,7 +79,10 @@ func (c *container) View() string {
7679 PaddingBottom(c.paddingBottom).
7780 PaddingLeft(c.paddingLeft)
7881
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
8086}
8187
8288func (c *container) SetSize(width, height int) tea.Cmd {
@@ -115,6 +121,15 @@ func (c *container) GetSize() (int, int) {
115121 return c.width, c.height
116122}
117123
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+
118133func (c *container) BindingKeys() []key.Binding {
119134 if b, ok := c.content.(Bindings); ok {
120135 return b.BindingKeys()
internal/tui/layout/layout.go+4
@@ -22,6 +22,10 @@ type Bindings interface {
2222 BindingKeys() []key.Binding
2323}
2424
25+type Positionable interface {
26+ SetPosition(x, y int) tea.Cmd
27+}
28+
2529func KeyMapToSlice(t any) (bindings []key.Binding) {
2630 typ := reflect.TypeOf(t)
2731 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) {
8686 return s, tea.Batch(cmds...)
8787}
8888
89−func (s *splitPaneLayout) View() string {
89+func (s *splitPaneLayout) View() tea.View {
9090 var topSection string
9191
9292 if s.leftPanel != nil && s.rightPanel != nil {
9393 leftView := s.leftPanel.View()
9494 rightView := s.rightPanel.View()
95− topSection = lipgloss.JoinHorizontal(lipgloss.Top, leftView, rightView)
95+ topSection = lipgloss.JoinHorizontal(lipgloss.Top, leftView.String(), rightView.String())
9696 } else if s.leftPanel != nil {
97− topSection = s.leftPanel.View()
97+ topSection = s.leftPanel.View().String()
9898 } else if s.rightPanel != nil {
99− topSection = s.rightPanel.View()
99+ topSection = s.rightPanel.View().String()
100100 } else {
101101 topSection = ""
102102 }
@@ -105,25 +105,33 @@ func (s *splitPaneLayout) View() string {
105105
106106 if s.bottomPanel != nil && topSection != "" {
107107 bottomView := s.bottomPanel.View()
108− finalView = lipgloss.JoinVertical(lipgloss.Left, topSection, bottomView)
108+ finalView = lipgloss.JoinVertical(lipgloss.Left, topSection, bottomView.String())
109109 } else if s.bottomPanel != nil {
110− finalView = s.bottomPanel.View()
110+ finalView = s.bottomPanel.View().String()
111111 } else {
112112 finalView = topSection
113113 }
114114
115− if finalView != "" {
116− t := theme.CurrentTheme()
115+ // TODO: think of a better way to handle multiple cursors
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+ }
117124
118− style := lipgloss.NewStyle().
119− Width(s.width).
120− Height(s.height).
121− Background(t.Background())
125+ t := theme.CurrentTheme()
122126
123− return style.Render(finalView)
124− }
127+ style := lipgloss.NewStyle().
128+ Width(s.width).
129+ Height(s.height).
130+ Background(t.Background())
125131
126− return finalView
132+ view := tea.NewView(style.Render(finalView))
133+ view.SetCursor(cursor)
134+ return view
127135}
128136
129137func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
@@ -131,6 +139,7 @@ func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
131139 s.height = height
132140
133141 var topHeight, bottomHeight int
142+ var cmds []tea.Cmd
134143 if s.bottomPanel != nil {
135144 topHeight = int(float64(height) * s.verticalRatio)
136145 bottomHeight = height - topHeight
@@ -151,20 +160,28 @@ func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
151160 rightWidth = width
152161 }
153162
154− var cmds []tea.Cmd
155163 if s.leftPanel != nil {
156164 cmd := s.leftPanel.SetSize(leftWidth, topHeight)
157165 cmds = append(cmds, cmd)
166+ if positionable, ok := s.leftPanel.(Positionable); ok {
167+ cmds = append(cmds, positionable.SetPosition(0, 0))
168+ }
158169 }
159170
160171 if s.rightPanel != nil {
161172 cmd := s.rightPanel.SetSize(rightWidth, topHeight)
162173 cmds = append(cmds, cmd)
174+ if positionable, ok := s.rightPanel.(Positionable); ok {
175+ cmds = append(cmds, positionable.SetPosition(leftWidth, 0))
176+ }
163177 }
164178
165179 if s.bottomPanel != nil {
166180 cmd := s.bottomPanel.SetSize(width, bottomHeight)
167181 cmds = append(cmds, cmd)
182+ if positionable, ok := s.bottomPanel.(Positionable); ok {
183+ cmds = append(cmds, positionable.SetPosition(0, topHeight))
184+ }
168185 }
169186 return tea.Batch(cmds...)
170187}
internal/tui/page/chat.go+10−5
@@ -185,7 +185,7 @@ func (p *chatPage) GetSize() (int, int) {
185185 return p.layout.GetSize()
186186}
187187
188−func (p *chatPage) View() string {
188+func (p *chatPage) View() tea.View {
189189 layoutView := p.layout.View()
190190
191191 if p.showCompletionDialog {
@@ -195,15 +195,20 @@ func (p *chatPage) View() string {
195195 p.completionDialog.SetWidth(editorWidth)
196196 overlay := p.completionDialog.View()
197197
198− layoutView = layout.PlaceOverlay(
198+ viewStr := layout.PlaceOverlay(
199199 0,
200− layoutHeight-editorHeight-lipgloss.Height(overlay),
201− overlay,
202− layoutView,
200+ layoutHeight-editorHeight-lipgloss.Height(overlay.String()),
201+ overlay.String(),
202+ layoutView.String(),
203203 false,
204204 )
205+
206+ view := tea.NewView(viewStr)
207+ view.SetCursor(overlay.Cursor())
208+ return view
205209 }
206210
211+ logging.Info("Cursor in page", "c", layoutView.Cursor())
207212 return layoutView
208213}
209214
internal/tui/page/logs.go+9−5
@@ -42,12 +42,16 @@ func (p *logsPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
4242 return p, tea.Batch(cmds...)
4343}
4444
45−func (p *logsPage) View() string {
45+func (p *logsPage) View() tea.View {
4646 style := styles.BaseStyle().Width(p.width).Height(p.height)
47− return style.Render(lipgloss.JoinVertical(lipgloss.Top,
48− p.table.View(),
49− p.details.View(),
50− ))
47+ return tea.NewView(
48+ style.Render(
49+ lipgloss.JoinVertical(lipgloss.Top,
50+ p.table.View().String(),
51+ p.details.View().String(),
52+ ),
53+ ),
54+ )
5155}
5256
5357func (p *logsPage) BindingKeys() []key.Binding {
internal/tui/tui.go+821−851
@@ -1,676 +1,635 @@
11package tui
22
33import (
4− "context"
5− "fmt"
6− "strings"
7−
84 "github.com/charmbracelet/bubbles/v2/key"
95 tea "github.com/charmbracelet/bubbletea/v2"
106 "github.com/charmbracelet/lipgloss/v2"
117 "github.com/opencode-ai/opencode/internal/app"
12− "github.com/opencode-ai/opencode/internal/config"
13− "github.com/opencode-ai/opencode/internal/llm/agent"
148 "github.com/opencode-ai/opencode/internal/logging"
15− "github.com/opencode-ai/opencode/internal/permission"
169 "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"
1910 "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"
2114 "github.com/opencode-ai/opencode/internal/tui/layout"
2215 "github.com/opencode-ai/opencode/internal/tui/page"
2316 "github.com/opencode-ai/opencode/internal/tui/theme"
2417 "github.com/opencode-ai/opencode/internal/tui/util"
2518)
2619
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{}
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−)
20+// type startCompactSessionMsg struct{}
9721
9822type appModel struct {
99− width, height int
100− currentPage page.PageID
101− previousPage page.PageID
102− pages map[page.PageID]util.Model
103− loadedPages map[page.PageID]bool
104− status core.StatusCmp
105− app *app.App
106− selectedSession session.Session
107−
108− showPermissions bool
109− permissions dialog.PermissionDialogCmp
110−
111− showHelp bool
112− help dialog.HelpCmp
113−
114− showQuit bool
115− quit dialog.QuitDialog
116−
117− showSessionDialog bool
118− sessionDialog dialog.SessionDialog
119−
120− showCommandDialog bool
121− commandDialog dialog.CommandDialog
122− commands []dialog.Command
123−
124− showModelDialog bool
125− modelDialog dialog.ModelDialog
126−
127− showInitDialog bool
128− initDialog dialog.InitDialogCmp
129−
130− showFilepicker bool
131− filepicker dialog.FilepickerCmp
132−
133− showThemeDialog bool
134− themeDialog dialog.ThemeDialog
135−
136− showMultiArgumentsDialog bool
137− multiArgumentsDialog dialog.MultiArgumentsDialogCmp
138−
139− isCompacting bool
140− compactingMessage string
23+ width, height int
24+ keyMap KeyMap
25+
26+ currentPage page.PageID
27+ previousPage page.PageID
28+ pages map[page.PageID]util.Model
29+ loadedPages map[page.PageID]bool
30+
31+ status core.StatusCmp
32+
33+ app *app.App
34+
35+ // selectedSession session.Session
36+ //
37+ // showPermissions bool
38+ // permissions dialog.PermissionDialogCmp
39+ //
40+ // showHelp bool
41+ // help dialog.HelpCmp
42+ //
43+ // showSessionDialog bool
44+ // sessionDialog dialog.SessionDialog
45+ //
46+ // showCommandDialog bool
47+ // commandDialog dialog.CommandDialog
48+ // commands []dialog.Command
49+ //
50+ // showModelDialog bool
51+ // modelDialog dialog.ModelDialog
52+ //
53+ // showInitDialog bool
54+ // initDialog dialog.InitDialogCmp
55+ //
56+ // showFilepicker bool
57+ // filepicker dialog.FilepickerCmp
58+ //
59+ // showThemeDialog bool
60+ // themeDialog dialog.ThemeDialog
61+ //
62+ // showMultiArgumentsDialog bool
63+ // multiArgumentsDialog dialog.MultiArgumentsDialogCmp
64+ //
65+ // isCompacting bool
66+ // compactingMessage string
67+
68+ // NEW DIALOG
69+ dialog dialogs.DialogCmp
14170}
14271
14372func (a appModel) Init() tea.Cmd {
14473 var cmds []tea.Cmd
14574 cmd := a.pages[a.currentPage].Init()
146− t := theme.CurrentTheme()
147− cmds = append(cmds, tea.SetBackgroundColor(t.Background()))
148− a.loadedPages[a.currentPage] = true
14975 cmds = append(cmds, cmd)
76+ a.loadedPages[a.currentPage] = true
77+
15078 cmd = a.status.Init()
15179 cmds = append(cmds, cmd)
152− cmd = a.quit.Init()
153− cmds = append(cmds, cmd)
154− cmd = a.help.Init()
155− cmds = append(cmds, cmd)
156− cmd = a.sessionDialog.Init()
157− cmds = append(cmds, cmd)
158− cmd = a.commandDialog.Init()
159− cmds = append(cmds, cmd)
160− cmd = a.modelDialog.Init()
161− cmds = append(cmds, cmd)
162− cmd = a.initDialog.Init()
163− cmds = append(cmds, cmd)
164− cmd = a.filepicker.Init()
165− cmds = append(cmds, cmd)
166− cmd = a.themeDialog.Init()
167− cmds = append(cmds, cmd)
80+ // cmd = a.help.Init()
81+ // cmds = append(cmds, cmd)
82+ // cmd = a.sessionDialog.Init()
83+ // cmds = append(cmds, cmd)
84+ // cmd = a.commandDialog.Init()
85+ // cmds = append(cmds, cmd)
86+ // cmd = a.modelDialog.Init()
87+ // cmds = append(cmds, cmd)
88+ // cmd = a.initDialog.Init()
89+ // cmds = append(cmds, cmd)
90+ // cmd = a.filepicker.Init()
91+ // cmds = append(cmds, cmd)
92+ // cmd = a.themeDialog.Init()
93+ // cmds = append(cmds, cmd)
16894
16995 // Check if we should show the init dialog
170− cmds = append(cmds, func() tea.Msg {
171− shouldShow, err := config.ShouldShowInitDialog()
172− if err != nil {
173− return util.InfoMsg{
174− Type: util.InfoTypeError,
175− Msg: "Failed to check init status: " + err.Error(),
176− }
177− }
178− return dialog.ShowInitDialogMsg{Show: shouldShow}
179− })
96+ // cmds = append(cmds, func() tea.Msg {
97+ // shouldShow, err := config.ShouldShowInitDialog()
98+ // if err != nil {
99+ // return util.InfoMsg{
100+ // Type: util.InfoTypeError,
101+ // Msg: "Failed to check init status: " + err.Error(),
102+ // }
103+ // }
104+ // return dialog.ShowInitDialogMsg{Show: shouldShow}
105+ // })
180106
107+ t := theme.CurrentTheme()
108+ cmds = append(cmds, tea.SetBackgroundColor(t.Background()))
181109 return tea.Batch(cmds...)
182110}
183111
184−func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
112+func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
185113 var cmds []tea.Cmd
186114 var cmd tea.Cmd
115+
187116 switch msg := msg.(type) {
188117 case tea.WindowSizeMsg:
189− msg.Height -= 1 // Make space for the status bar
190− a.width, a.height = msg.Width, msg.Height
191−
192− s, _ := a.status.Update(msg)
193− a.status = s.(core.StatusCmp)
194− updated, cmd := a.pages[a.currentPage].Update(msg)
195− a.pages[a.currentPage] = updated.(util.Model)
196− cmds = append(cmds, cmd)
197−
198− prm, permCmd := a.permissions.Update(msg)
199− a.permissions = prm.(dialog.PermissionDialogCmp)
200− cmds = append(cmds, permCmd)
201−
202− help, helpCmd := a.help.Update(msg)
203− a.help = help.(dialog.HelpCmp)
204− cmds = append(cmds, helpCmd)
205−
206− session, sessionCmd := a.sessionDialog.Update(msg)
207− a.sessionDialog = session.(dialog.SessionDialog)
208− cmds = append(cmds, sessionCmd)
209−
210− command, commandCmd := a.commandDialog.Update(msg)
211− a.commandDialog = command.(dialog.CommandDialog)
212− cmds = append(cmds, commandCmd)
213−
214− filepicker, filepickerCmd := a.filepicker.Update(msg)
215− a.filepicker = filepicker.(dialog.FilepickerCmp)
216− cmds = append(cmds, filepickerCmd)
217−
218− a.initDialog.SetSize(msg.Width, msg.Height)
219−
220− if a.showMultiArgumentsDialog {
221− a.multiArgumentsDialog.SetSize(msg.Width, msg.Height)
222− args, argsCmd := a.multiArgumentsDialog.Update(msg)
223− a.multiArgumentsDialog = args.(dialog.MultiArgumentsDialogCmp)
224− cmds = append(cmds, argsCmd, a.multiArgumentsDialog.Init())
225− }
118+ return a, a.handleWindowResize(msg)
119+ // TODO: remove when refactor is done
120+ // msg.Height -= 1 // Make space for the status bar
121+ // a.width, a.height = msg.Width, msg.Height
122+ //
123+ // s, _ := a.status.Update(msg)
124+ // a.status = s.(core.StatusCmp)
125+ // updated, cmd := a.pages[a.currentPage].Update(msg)
126+ // a.pages[a.currentPage] = updated.(util.Model)
127+ // cmds = append(cmds, cmd)
128+ //
129+ // prm, permCmd := a.permissions.Update(msg)
130+ // a.permissions = prm.(dialog.PermissionDialogCmp)
131+ // cmds = append(cmds, permCmd)
132+ //
133+ // help, helpCmd := a.help.Update(msg)
134+ // a.help = help.(dialog.HelpCmp)
135+ // cmds = append(cmds, helpCmd)
136+ //
137+ // session, sessionCmd := a.sessionDialog.Update(msg)
138+ // a.sessionDialog = session.(dialog.SessionDialog)
139+ // cmds = append(cmds, sessionCmd)
140+ //
141+ // command, commandCmd := a.commandDialog.Update(msg)
142+ // a.commandDialog = command.(dialog.CommandDialog)
143+ // cmds = append(cmds, commandCmd)
144+ //
145+ // filepicker, filepickerCmd := a.filepicker.Update(msg)
146+ // a.filepicker = filepicker.(dialog.FilepickerCmp)
147+ // cmds = append(cmds, filepickerCmd)
148+ //
149+ // a.initDialog.SetSize(msg.Width, msg.Height)
150+ //
151+ // if a.showMultiArgumentsDialog {
152+ // a.multiArgumentsDialog.SetSize(msg.Width, msg.Height)
153+ // args, argsCmd := a.multiArgumentsDialog.Update(msg)
154+ // a.multiArgumentsDialog = args.(dialog.MultiArgumentsDialogCmp)
155+ // cmds = append(cmds, argsCmd, a.multiArgumentsDialog.Init())
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)
226173
227− return a, tea.Batch(cmds...)
228− // Status
229− case util.InfoMsg:
174+ // Status Messages
175+ case util.InfoMsg, util.ClearStatusMsg:
230176 s, cmd := a.status.Update(msg)
231177 a.status = s.(core.StatusCmp)
232178 cmds = append(cmds, cmd)
233179 return a, tea.Batch(cmds...)
180+ // Logs
234181 case pubsub.Event[logging.LogMessage]:
235− if msg.Payload.Persist {
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)
182+ // Send to the status component
183+ s, cmd := a.status.Update(msg)
275184 a.status = s.(core.StatusCmp)
185+ cmds = append(cmds, cmd)
276186
277− // Permission
278− case pubsub.Event[permission.PermissionRequest]:
279− a.showPermissions = true
280− return a, a.permissions.SetPermissions(msg.Payload)
281− case dialog.PermissionResponseMsg:
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)
187+ // If the current page is logs, update the logs view
188+ if a.currentPage == page.LogsPage {
189+ updated, cmd := a.pages[a.currentPage].Update(msg)
190+ a.pages[a.currentPage] = updated.(util.Model)
191+ cmds = append(cmds, cmd)
290192 }
291− a.showPermissions = false
292− return a, cmd
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..."
193+ return a, tea.Batch(cmds...)
313194
314− if a.selectedSession.ID == "" {
315− a.isCompacting = false
316− return a, util.ReportWarn("No active session to summarize")
317− }
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+ //
232+ // if a.selectedSession.ID == "" {
233+ // a.isCompacting = false
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...)
574+}
318575
319− // Start the summarization process
320− return a, func() tea.Msg {
321− ctx := context.Background()
322− a.app.CoderAgent.Summarize(ctx, a.selectedSession.ID)
323− return nil
324− }
576+func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
577+ var cmds []tea.Cmd
578+ msg.Height -= 1 // Make space for the status bar
579+ a.width, a.height = msg.Width, msg.Height
325580
326− case pubsub.Event[agent.AgentEvent]:
327− payload := msg.Payload
328− if payload.Error != nil {
329− a.isCompacting = false
330− return a, util.ReportError(payload.Error)
331− }
581+ // Update status bar
582+ s, cmd := a.status.Update(msg)
583+ a.status = s.(core.StatusCmp)
584+ cmds = append(cmds, cmd)
332585
333− a.compactingMessage = payload.Progress
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)
355− a.pages[a.currentPage] = updated.(util.Model)
356− a.showThemeDialog = false
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− }
586+ // Update the current page
587+ updated, cmd := a.pages[a.currentPage].Update(msg)
588+ a.pages[a.currentPage] = updated.(util.Model)
589+ cmds = append(cmds, cmd)
371590
372− return a, util.ReportInfo(fmt.Sprintf("Model changed to %s", model.Name))
373−
374− case dialog.ShowInitDialogMsg:
375− a.showInitDialog = msg.Show
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
591+ // Update the dialogs
592+ dialog, cmd := a.dialog.Update(msg)
593+ a.dialog = dialog.(dialogs.DialogCmp)
594+ cmds = append(cmds, cmd)
398595
399− case chat.SessionSelectedMsg:
400− a.selectedSession = msg
401− a.sessionDialog.SetSelectedSession(msg.ID)
596+ return tea.Batch(cmds...)
597+}
402598
403− case pubsub.Event[session.Session]:
404− if msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == a.selectedSession.ID {
405− a.selectedSession = msg.Payload
406− }
407− case dialog.SessionSelectedMsg:
408− a.showSessionDialog = false
409− if a.currentPage == page.ChatPage {
410− return a, util.CmdHandler(chat.SessionSelectedMsg(msg.Session))
411− }
412− return a, nil
599+func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
600+ switch {
601+ // dialogs
602+ case key.Matches(msg, a.keyMap.Quit):
603+ return util.CmdHandler(dialogs.OpenDialogMsg{
604+ Model: quit.NewQuitDialog(),
605+ })
413606
414− case dialog.CommandSelectedMsg:
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{
444− Content: content,
445− Args: msg.Args,
446− })
447− }
448− return a, nil
607+ case key.Matches(msg, a.keyMap.Commands):
608+ return util.CmdHandler(dialogs.OpenDialogMsg{
609+ Model: commands.NewCommandDialog(),
610+ })
449611
450− case tea.KeyPressMsg:
451− // If multi-arguments dialog is open, let it handle the key press first
452− if a.showMultiArgumentsDialog {
453− args, cmd := a.multiArgumentsDialog.Update(msg)
454− a.multiArgumentsDialog = args.(dialog.MultiArgumentsDialogCmp)
455− return a, cmd
456− }
612+ // Page navigation
613+ case key.Matches(msg, a.keyMap.Logs):
614+ return a.moveToPage(page.LogsPage)
457615
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− }
577616 default:
578− f, filepickerCmd := a.filepicker.Update(msg)
579− a.filepicker = f.(dialog.FilepickerCmp)
580− cmds = append(cmds, filepickerCmd)
581− }
582−
583− if a.showFilepicker {
584− f, filepickerCmd := a.filepicker.Update(msg)
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...)
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...)
617+ if a.dialog.HasDialogs() {
618+ u, dialogCmd := a.dialog.Update(msg)
619+ a.dialog = u.(dialogs.DialogCmp)
620+ return dialogCmd
621+ } else {
622+ updated, cmd := a.pages[a.currentPage].Update(msg)
623+ a.pages[a.currentPage] = updated.(util.Model)
624+ return cmd
659625 }
660626 }
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...)
668627}
669628
670629// RegisterCommand adds a command to the command dialog
671−func (a *appModel) RegisterCommand(cmd dialog.Command) {
672− a.commands = append(a.commands, cmd)
673−}
630+// func (a *appModel) RegisterCommand(cmd dialog.Command) {
631+// a.commands = append(a.commands, cmd)
632+// }
674633
675634func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
676635 if a.app.CoderAgent.IsBusy() {
@@ -694,265 +653,276 @@ func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
694653 return tea.Batch(cmds...)
695654}
696655
697−func (a appModel) View() string {
656+func (a *appModel) View() tea.View {
657+ pageView := a.pages[a.currentPage].View()
698658 components := []string{
699− a.pages[a.currentPage].View(),
659+ pageView.String(),
700660 }
701− components = append(components, a.status.View())
661+ components = append(components, a.status.View().String())
702662
703663 appView := lipgloss.JoinVertical(lipgloss.Top, components...)
704664
705− if a.showPermissions {
706− overlay := a.permissions.View()
707− row := lipgloss.Height(appView) / 2
708− row -= lipgloss.Height(overlay) / 2
709− col := lipgloss.Width(appView) / 2
710− col -= lipgloss.Width(overlay) / 2
711− appView = layout.PlaceOverlay(
712− col,
713− row,
714− overlay,
715− appView,
716− true,
717− )
718− }
719−
720− if a.showFilepicker {
721− overlay := a.filepicker.View()
722− row := lipgloss.Height(appView) / 2
723− row -= lipgloss.Height(overlay) / 2
724− col := lipgloss.Width(appView) / 2
725− col -= lipgloss.Width(overlay) / 2
726− appView = layout.PlaceOverlay(
727− col,
728− row,
729− overlay,
730− appView,
731− true,
732− )
733− }
734−
735− // Show compacting status overlay
736− if a.isCompacting {
737− t := theme.CurrentTheme()
738− style := lipgloss.NewStyle().
739− Border(lipgloss.RoundedBorder()).
740− BorderForeground(t.BorderFocused()).
741− BorderBackground(t.Background()).
742− Padding(1, 2).
743− Background(t.Background()).
744− Foreground(t.Text())
745−
746− overlay := style.Render("Summarizing\n" + a.compactingMessage)
747− row := lipgloss.Height(appView) / 2
748− row -= lipgloss.Height(overlay) / 2
749− col := lipgloss.Width(appView) / 2
750− col -= lipgloss.Width(overlay) / 2
751− appView = layout.PlaceOverlay(
752− col,
753− row,
754− overlay,
755− appView,
756− true,
757− )
758− }
759−
760− if a.showHelp {
761− bindings := layout.KeyMapToSlice(keys)
762− if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
763− bindings = append(bindings, p.BindingKeys()...)
764− }
765− if a.showPermissions {
766− bindings = append(bindings, a.permissions.BindingKeys()...)
767− }
768− if a.currentPage == page.LogsPage {
769− bindings = append(bindings, logsKeyReturnKey)
770− }
771− if !a.app.CoderAgent.IsBusy() {
772− bindings = append(bindings, helpEsc)
773− }
774− a.help.SetBindings(bindings)
775−
776− overlay := a.help.View()
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
793− row -= lipgloss.Height(overlay) / 2
794− col := lipgloss.Width(appView) / 2
795− col -= lipgloss.Width(overlay) / 2
796− appView = layout.PlaceOverlay(
797− col,
798− row,
799− overlay,
800− appView,
801− true,
802− )
803− }
804−
805− if a.showSessionDialog {
806− overlay := a.sessionDialog.View()
807− row := lipgloss.Height(appView) / 2
808− row -= lipgloss.Height(overlay) / 2
809− col := lipgloss.Width(appView) / 2
810− col -= lipgloss.Width(overlay) / 2
811− appView = layout.PlaceOverlay(
812− col,
813− row,
814− overlay,
815− appView,
816− true,
817− )
818− }
819−
820− if a.showModelDialog {
821− overlay := a.modelDialog.View()
822− row := lipgloss.Height(appView) / 2
823− row -= lipgloss.Height(overlay) / 2
824− col := lipgloss.Width(appView) / 2
825− col -= lipgloss.Width(overlay) / 2
826− appView = layout.PlaceOverlay(
827− col,
828− row,
829− overlay,
830− appView,
831− true,
832− )
833− }
834−
835− if a.showCommandDialog {
836− overlay := a.commandDialog.View()
837− row := lipgloss.Height(appView) / 2
838− row -= lipgloss.Height(overlay) / 2
839− col := lipgloss.Width(appView) / 2
840− col -= lipgloss.Width(overlay) / 2
841− appView = layout.PlaceOverlay(
842− col,
843− row,
844− overlay,
845− appView,
846− true,
847− )
848− }
849−
850− if a.showInitDialog {
851− overlay := a.initDialog.View()
852− appView = layout.PlaceOverlay(
853− a.width/2-lipgloss.Width(overlay)/2,
854− a.height/2-lipgloss.Height(overlay)/2,
855− overlay,
856− appView,
857− true,
858− )
859− }
860−
861− if a.showThemeDialog {
862− overlay := a.themeDialog.View()
863− row := lipgloss.Height(appView) / 2
864− row -= lipgloss.Height(overlay) / 2
865− col := lipgloss.Width(appView) / 2
866− col -= lipgloss.Width(overlay) / 2
867− appView = layout.PlaceOverlay(
868− col,
869− row,
870− overlay,
871− appView,
872− true,
665+ // if a.showPermissions {
666+ // overlay := a.permissions.View().String()
667+ // row := lipgloss.Height(appView) / 2
668+ // row -= lipgloss.Height(overlay) / 2
669+ // col := lipgloss.Width(appView) / 2
670+ // col -= lipgloss.Width(overlay) / 2
671+ // appView = layout.PlaceOverlay(
672+ // col,
673+ // row,
674+ // overlay,
675+ // appView,
676+ // true,
677+ // )
678+ // }
679+ //
680+ // if a.showFilepicker {
681+ // overlay := a.filepicker.View().String()
682+ // row := lipgloss.Height(appView) / 2
683+ // row -= lipgloss.Height(overlay) / 2
684+ // col := lipgloss.Width(appView) / 2
685+ // col -= lipgloss.Width(overlay) / 2
686+ // appView = layout.PlaceOverlay(
687+ // col,
688+ // row,
689+ // overlay,
690+ // appView,
691+ // true,
692+ // )
693+ // }
694+ //
695+ // // Show compacting status overlay
696+ // if a.isCompacting {
697+ // t := theme.CurrentTheme()
698+ // style := lipgloss.NewStyle().
699+ // Border(lipgloss.RoundedBorder()).
700+ // BorderForeground(t.BorderFocused()).
701+ // BorderBackground(t.Background()).
702+ // Padding(1, 2).
703+ // Background(t.Background()).
704+ // Foreground(t.Text())
705+ //
706+ // overlay := style.Render("Summarizing\n" + a.compactingMessage)
707+ // row := lipgloss.Height(appView) / 2
708+ // row -= lipgloss.Height(overlay) / 2
709+ // col := lipgloss.Width(appView) / 2
710+ // col -= lipgloss.Width(overlay) / 2
711+ // appView = layout.PlaceOverlay(
712+ // col,
713+ // row,
714+ // overlay,
715+ // appView,
716+ // true,
717+ // )
718+ // }
719+ //
720+ // if a.showHelp {
721+ // bindings := layout.KeyMapToSlice(a.keymap)
722+ // if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
723+ // bindings = append(bindings, p.BindingKeys()...)
724+ // }
725+ // if a.showPermissions {
726+ // bindings = append(bindings, a.permissions.BindingKeys()...)
727+ // }
728+ // if a.currentPage == page.LogsPage {
729+ // // bindings = append(bindings, logsKeyReturnKey)
730+ // }
731+ // if !a.app.CoderAgent.IsBusy() {
732+ // // bindings = append(bindings, helpEsc)
733+ // }
734+ //
735+ // a.help.SetBindings(bindings)
736+ //
737+ // overlay := a.help.View().String()
738+ // row := lipgloss.Height(appView) / 2
739+ // row -= lipgloss.Height(overlay) / 2
740+ // col := lipgloss.Width(appView) / 2
741+ // col -= lipgloss.Width(overlay) / 2
742+ // appView = layout.PlaceOverlay(
743+ // col,
744+ // row,
745+ // overlay,
746+ // appView,
747+ // true,
748+ // )
749+ // }
750+ //
751+ // if a.showSessionDialog {
752+ // overlay := a.sessionDialog.View().String()
753+ // row := lipgloss.Height(appView) / 2
754+ // row -= lipgloss.Height(overlay) / 2
755+ // col := lipgloss.Width(appView) / 2
756+ // col -= lipgloss.Width(overlay) / 2
757+ // appView = layout.PlaceOverlay(
758+ // col,
759+ // row,
760+ // overlay,
761+ // appView,
762+ // true,
763+ // )
764+ // }
765+ //
766+ // if a.showModelDialog {
767+ // overlay := a.modelDialog.View().String()
768+ // row := lipgloss.Height(appView) / 2
769+ // row -= lipgloss.Height(overlay) / 2
770+ // col := lipgloss.Width(appView) / 2
771+ // col -= lipgloss.Width(overlay) / 2
772+ // appView = layout.PlaceOverlay(
773+ // col,
774+ // row,
775+ // overlay,
776+ // appView,
777+ // true,
778+ // )
779+ // }
780+ //
781+ // if a.showCommandDialog {
782+ // overlay := a.commandDialog.View().String()
783+ // row := lipgloss.Height(appView) / 2
784+ // row -= lipgloss.Height(overlay) / 2
785+ // col := lipgloss.Width(appView) / 2
786+ // col -= lipgloss.Width(overlay) / 2
787+ // appView = layout.PlaceOverlay(
788+ // col,
789+ // row,
790+ // overlay,
791+ // appView,
792+ // true,
793+ // )
794+ // }
795+ //
796+ // if a.showInitDialog {
797+ // overlay := a.initDialog.View()
798+ // appView = layout.PlaceOverlay(
799+ // a.width/2-lipgloss.Width(overlay)/2,
800+ // a.height/2-lipgloss.Height(overlay)/2,
801+ // overlay,
802+ // appView,
803+ // true,
804+ // )
805+ // }
806+ //
807+ // if a.showThemeDialog {
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()
824+ // row := lipgloss.Height(appView) / 2
825+ // row -= lipgloss.Height(overlay) / 2
826+ // col := lipgloss.Width(appView) / 2
827+ // col -= lipgloss.Width(overlay) / 2
828+ // appView = layout.PlaceOverlay(
829+ // col,
830+ // row,
831+ // overlay,
832+ // appView,
833+ // true,
834+ // )
835+ // }
836+ t := theme.CurrentTheme()
837+ if a.dialog.HasDialogs() {
838+ layers := append(
839+ []*lipgloss.Layer{
840+ lipgloss.NewLayer(appView),
841+ },
842+ a.dialog.GetLayers()...,
873843 )
874− }
875−
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,
844+ canvas := lipgloss.NewCanvas(
845+ layers...,
888846 )
847+ view := tea.NewView(canvas.Render())
848+ activeView := a.dialog.ActiveView()
849+ view.SetBackgroundColor(t.Background())
850+ view.SetCursor(activeView.Cursor())
851+ return view
889852 }
890853
891− return appView
854+ view := tea.NewView(appView)
855+ view.SetCursor(pageView.Cursor())
856+ view.SetBackgroundColor(t.Background())
857+ return view
892858}
893859
894860func New(app *app.App) tea.Model {
895861 startPage := page.ChatPage
896862 model := &appModel{
897− currentPage: startPage,
898− loadedPages: make(map[page.PageID]bool),
899− status: core.NewStatusCmp(app.LSPClients),
900− help: dialog.NewHelpCmp(),
901− quit: dialog.NewQuitCmp(),
902− sessionDialog: dialog.NewSessionDialogCmp(),
903− commandDialog: dialog.NewCommandDialogCmp(),
904− modelDialog: dialog.NewModelDialogCmp(),
905− permissions: dialog.NewPermissionDialogCmp(),
906− initDialog: dialog.NewInitDialogCmp(),
907− themeDialog: dialog.NewThemeDialogCmp(),
908− app: app,
909− commands: []dialog.Command{},
863+ currentPage: startPage,
864+ app: app,
865+ status: core.NewStatusCmp(app.LSPClients),
866+ loadedPages: make(map[page.PageID]bool),
867+ keyMap: DefaultKeyMap(),
868+
869+ // help: dialog.NewHelpCmp(),
870+ // sessionDialog: dialog.NewSessionDialogCmp(),
871+ // commandDialog: dialog.NewCommandDialogCmp(),
872+ // modelDialog: dialog.NewModelDialogCmp(),
873+ // permissions: dialog.NewPermissionDialogCmp(),
874+ // initDialog: dialog.NewInitDialogCmp(),
875+ // themeDialog: dialog.NewThemeDialogCmp(),
876+ // commands: []dialog.Command{},
910877 pages: map[page.PageID]util.Model{
911878 page.ChatPage: page.NewChatPage(app),
912879 page.LogsPage: page.NewLogsPage(),
913880 },
914− filepicker: dialog.NewFilepickerCmp(app),
915− }
881+ // filepicker: dialog.NewFilepickerCmp(app),
916882
917− model.RegisterCommand(dialog.Command{
918− ID: "init",
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− }
883+ // New dialog
884+ dialog: dialogs.NewDialogCmp(),
955885 }
956886
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+
957927 return model
958928}
internal/tui/util/util.go+1−1
@@ -8,7 +8,7 @@ import (
88
99type Model interface {
1010 tea.Model
11− tea.ViewModel
11+ tea.Viewable
1212}
1313
1414func CmdHandler(msg tea.Msg) tea.Cmd {