From f1544004b32d2b6a640ee0a339ddb6220d418a84 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 27 May 2025 14:57:22 +0200 Subject: [PATCH] wip dialogs --- cspell.json | 1 + go.mod | 18 +- go.sum | 36 +- internal/tui/components/anim/anim.go | 6 +- internal/tui/components/chat/editor.go | 27 +- internal/tui/components/chat/list.go | 6 +- .../tui/components/chat/messages/messages.go | 10 +- .../tui/components/chat/messages/renderer.go | 2 +- internal/tui/components/chat/messages/tool.go | 10 +- internal/tui/components/chat/sidebar.go | 36 +- internal/tui/components/core/list/list.go | 13 +- internal/tui/components/core/status.go | 40 +- internal/tui/components/dialog/arguments.go | 13 +- internal/tui/components/dialog/commands.go | 18 +- internal/tui/components/dialog/complete.go | 22 +- internal/tui/components/dialog/filepicker.go | 6 +- internal/tui/components/dialog/help.go | 26 +- internal/tui/components/dialog/models.go | 16 +- internal/tui/components/dialog/permission.go | 4 +- internal/tui/components/dialog/session.go | 28 +- internal/tui/components/dialog/theme.go | 30 +- .../components/dialogs/commands/arguments.go | 16 + .../components/dialogs/commands/commands.go | 135 ++ .../tui/components/dialogs/commands/item.go | 55 + .../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 + .../{dialog => dialogs/quit}/quit.go | 106 +- internal/tui/components/logs/details.go | 4 +- internal/tui/components/logs/table.go | 4 +- internal/tui/components/util/simple-list.go | 18 +- internal/tui/keys.go | 75 + internal/tui/layout/container.go | 19 +- internal/tui/layout/layout.go | 4 + internal/tui/layout/split.go | 49 +- internal/tui/page/chat.go | 15 +- internal/tui/page/logs.go | 14 +- internal/tui/tui.go | 1672 ++++++++--------- internal/tui/util/util.go | 2 +- 40 files changed, 1926 insertions(+), 1094 deletions(-) create mode 100644 cspell.json create mode 100644 internal/tui/components/dialogs/commands/arguments.go create mode 100644 internal/tui/components/dialogs/commands/commands.go create mode 100644 internal/tui/components/dialogs/commands/item.go create mode 100644 internal/tui/components/dialogs/commands/loader.go create mode 100644 internal/tui/components/dialogs/dialogs.go create mode 100644 internal/tui/components/dialogs/keys.go create mode 100644 internal/tui/components/dialogs/quit/keys.go rename internal/tui/components/{dialog => dialogs/quit}/quit.go (51%) create mode 100644 internal/tui/keys.go diff --git a/cspell.json b/cspell.json new file mode 100644 index 0000000000000000000000000000000000000000..b7dbd552ca81fc12eeb287709e68c150a3b8f6f7 --- /dev/null +++ b/cspell.json @@ -0,0 +1 @@ +{"flagWords":[],"version":"0.2","words":["opencode","charmbracelet","lipgloss","bubbletea"],"language":"en"} \ No newline at end of file diff --git a/go.mod b/go.mod index 2891cac0969dd2c7f9ae6b512cb0d42130ffe2c0..30cf44c417849d7902bc7092a47f5bd925759fc5 100644 --- a/go.mod +++ b/go.mod @@ -11,11 +11,11 @@ require ( github.com/aymanbagabas/go-udiff v0.2.0 github.com/bmatcuk/doublestar/v4 v4.8.1 github.com/catppuccin/go v0.3.0 - github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 - github.com/charmbracelet/bubbletea/v2 v2.0.0-beta1 - github.com/charmbracelet/glamour/v2 v2.0.0-20250513163904-eeeced3bb3c6 - github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250513162854-28902d027c40 - github.com/charmbracelet/x/ansi v0.9.2 + github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318 + github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250526132317-434f93986a5c + github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe + github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c + github.com/charmbracelet/x/ansi v0.9.3-0.20250516160309-24eee56f89fa github.com/fsnotify/fsnotify v1.8.0 github.com/go-logfmt/logfmt v0.6.0 github.com/google/uuid v1.6.0 @@ -57,12 +57,12 @@ require ( github.com/aws/smithy-go v1.20.3 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect - github.com/charmbracelet/colorprofile v0.3.0 // indirect - github.com/charmbracelet/x/cellbuf v0.0.14-0.20250326144200-0875329e71da // indirect + github.com/charmbracelet/colorprofile v0.3.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect - github.com/charmbracelet/x/input v0.3.4 // indirect + github.com/charmbracelet/x/input v0.3.5-0.20250509021451-13796e822d86 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect - github.com/charmbracelet/x/windows v0.2.0 // indirect + github.com/charmbracelet/x/windows v0.2.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/disintegration/imaging v1.6.2 github.com/dlclark/regexp2 v1.11.4 // indirect diff --git a/go.sum b/go.sum index dfa16aaaba641df5043e9d2af89b626cc34bfc17..2cf341113db9f55d2127cbdba61c679cc4bdfe8d 100644 --- a/go.sum +++ b/go.sum @@ -68,30 +68,30 @@ github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= -github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 h1:swACzss0FjnyPz1enfX56GKkLiuKg5FlyVmOLIlU2kE= -github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= -github.com/charmbracelet/bubbletea/v2 v2.0.0-beta1 h1:yaxFt97mvofGY7bYZn8U/aSVoamXGE3O4AEvWhshUDI= -github.com/charmbracelet/bubbletea/v2 v2.0.0-beta1/go.mod h1:qbcZLI5z8R49v9xBdU5V5Dh5D2uccx8wSwBqxQyErqc= -github.com/charmbracelet/colorprofile v0.3.0 h1:KtLh9uuu1RCt+Hml4s6Hz+kB1PfV3wi++1h5ia65yKQ= -github.com/charmbracelet/colorprofile v0.3.0/go.mod h1:oHJ340RS2nmG1zRGPmhJKJ/jf4FPNNk0P39/wBPA1G0= -github.com/charmbracelet/glamour/v2 v2.0.0-20250513163904-eeeced3bb3c6 h1:AKhOV8dSRU3KpqMgpGME9JU7ouumB2S6hMmD6PRJeTc= -github.com/charmbracelet/glamour/v2 v2.0.0-20250513163904-eeeced3bb3c6/go.mod h1:7xBAUTCSADx9mHG0uBf4NDoVpYxMzIQ2j/NMLGdFsFM= -github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250513162854-28902d027c40 h1:SxOUomYAVo5zh+6WCH1bGshlAnSKP0ZeovI0FHAl9kg= -github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250513162854-28902d027c40/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc= -github.com/charmbracelet/x/ansi v0.9.2 h1:92AGsQmNTRMzuzHEYfCdjQeUzTrgE1vfO5/7fEVoXdY= -github.com/charmbracelet/x/ansi v0.9.2/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.14-0.20250326144200-0875329e71da h1:8MGKD5WBtuzfXglq0CnyzVSwGojv57X+H46OL9OUyRA= -github.com/charmbracelet/x/cellbuf v0.0.14-0.20250326144200-0875329e71da/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318 h1:f8Q0ybZGxT+St1JfPM7yoz/XFpbmtodcIehaom/9XT8= +github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= +github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250526132317-434f93986a5c h1:EoW1x1K2EDKYw1D7raqZqWKnwk21IZVpYqLHQVhz1ZU= +github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250526132317-434f93986a5c/go.mod h1:sXuGtrlVJo43r1fVGBM06E7PPb16oBl8rDRr6YgQOck= +github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= +github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= +github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe h1:i6ce4CcAlPpTj2ER69m1DBeLZ3RRcHnKExuwhKa3GfY= +github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe/go.mod h1:p3Q+aN4eQKeM5jhrmXPMgPrlKbmc59rWSnMsSA3udhk= +github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c h1:177KMz8zHRlEZJsWzafbKYh6OdjgvTspoH+UjaxgIXY= +github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c/go.mod h1:EJWvaCrhOhNGVZMvcjc0yVryl4qqpMs8tz0r9WyEkdQ= +github.com/charmbracelet/x/ansi v0.9.3-0.20250516160309-24eee56f89fa h1:JU05TLAB6nOEL46bxHDV/+e8umBX32ODsGbVkc7o7bk= +github.com/charmbracelet/x/ansi v0.9.3-0.20250516160309-24eee56f89fa/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa h1:lphz0Z3rsiOtMYiz8axkT24i9yFiueDhJbzyNUADmME= +github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk= github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw= github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= -github.com/charmbracelet/x/input v0.3.4 h1:Mujmnv/4DaitU0p+kIsrlfZl/UlmeLKw1wAP3e1fMN0= -github.com/charmbracelet/x/input v0.3.4/go.mod h1:JI8RcvdZWQIhn09VzeK3hdp4lTz7+yhiEdpEQtZN+2c= +github.com/charmbracelet/x/input v0.3.5-0.20250509021451-13796e822d86 h1:BxAEmOBIDajkgao3EsbBxKQCYvgYPGdT62WASLvtf4Y= +github.com/charmbracelet/x/input v0.3.5-0.20250509021451-13796e822d86/go.mod h1:62Rp/6EtTxoeJDSdtpA3tJp3y3ZRpsiekBSje+K8htA= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= -github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw= -github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s= +github.com/charmbracelet/x/windows v0.2.1 h1:3x7vnbpQrjpuq/4L+I4gNsG5htYoCiA5oe9hLjAij5I= +github.com/charmbracelet/x/windows v0.2.1/go.mod h1:ptZp16h40gDYqs5TSawSVW+yiLB13j4kSMA0lSCHL0M= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= diff --git a/internal/tui/components/anim/anim.go b/internal/tui/components/anim/anim.go index 8c8276de23600147bbae7ab3cef1483cd05b2904..fa6dabaccb1b4375dcb253c566537840a13b056a 100644 --- a/internal/tui/components/anim/anim.go +++ b/internal/tui/components/anim/anim.go @@ -239,7 +239,7 @@ func (a *anim) updateChars(chars *[]cyclingChar) { } // View renders the animation. -func (a anim) View() string { +func (a anim) View() tea.View { t := theme.CurrentTheme() var b strings.Builder @@ -259,10 +259,10 @@ func (a anim) View() string { textStyle.Render(string(c.currentValue)), ) } - return b.String() + textStyle.Render(a.ellipsis.View()) + return tea.NewView(b.String() + textStyle.Render(a.ellipsis.View())) } - return b.String() + return tea.NewView(b.String()) } func makeGradientRamp(length int) []color.Color { diff --git a/internal/tui/components/chat/editor.go b/internal/tui/components/chat/editor.go index 339b6dd23bad94849dcbf0d2df7aab341a16d295..430b0b4cf3f90cd399cc8fd7be73761e9cd77e92 100644 --- a/internal/tui/components/chat/editor.go +++ b/internal/tui/components/chat/editor.go @@ -26,6 +26,7 @@ import ( type editorCmp struct { width int height int + x, y int app *app.App session session.Session textarea textarea.Model @@ -116,7 +117,7 @@ func (m *editorCmp) openEditor() tea.Cmd { } func (m *editorCmp) Init() tea.Cmd { - return textarea.Blink + return nil } func (m *editorCmp) send() tea.Cmd { @@ -212,7 +213,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } -func (m *editorCmp) View() string { +func (m *editorCmp) View() tea.View { t := theme.CurrentTheme() // Style the prompt with theme colors @@ -221,16 +222,23 @@ func (m *editorCmp) View() string { Bold(true). Foreground(t.Primary()) + cursor := m.textarea.Cursor() + cursor.X = m.textarea.Cursor().X + m.x + 2 + cursor.Y = m.textarea.Cursor().Y + m.y + 1 if len(m.attachments) == 0 { - return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View()) + view := tea.NewView(lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View())) + view.SetCursor(cursor) + return view } m.textarea.SetHeight(m.height - 1) - return lipgloss.JoinVertical(lipgloss.Top, + view := tea.NewView(lipgloss.JoinVertical(lipgloss.Top, m.attachmentsContent(), lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View(), ), - ) + )) + view.SetCursor(cursor) + return view } func (m *editorCmp) SetSize(width, height int) tea.Cmd { @@ -275,6 +283,12 @@ func (m *editorCmp) BindingKeys() []key.Binding { return bindings } +func (m *editorCmp) SetPosition(x, y int) tea.Cmd { + m.x = x + m.y = y + return nil +} + func CreateTextArea(existing *textarea.Model) textarea.Model { t := theme.CurrentTheme() bgColor := t.Background() @@ -297,11 +311,12 @@ func CreateTextArea(existing *textarea.Model) textarea.Model { s.Focused = f s.Blurred = b - ta.Styles = s + ta.SetStyles(s) ta.Prompt = " " ta.ShowLineNumbers = false ta.CharLimit = -1 + ta.SetVirtualCursor(false) if existing != nil { ta.SetValue(existing.Value()) diff --git a/internal/tui/components/chat/list.go b/internal/tui/components/chat/list.go index e039e259f3223a5c3f6ef61cab4d7c77a228c062..732e1d51d231720a3af4bd505d799c8ff6e23ea7 100644 --- a/internal/tui/components/chat/list.go +++ b/internal/tui/components/chat/list.go @@ -104,11 +104,11 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // View renders the message list or an initial screen if empty. -func (m *messageListCmp) View() string { +func (m *messageListCmp) View() tea.View { if len(m.listCmp.Items()) == 0 { - return initialScreen() + return tea.NewView(initialScreen()) } - return lipgloss.JoinVertical(lipgloss.Left, m.listCmp.View()) + return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, m.listCmp.View().String())) } // handleChildSession handles messages from child sessions (agent tools). diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index b047af6bf36dc800e2755149d906f3ad2ee32a4c..f5420bacf923ee2cae5e18ea90c20d7950f36d3e 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/internal/tui/components/chat/messages/messages.go @@ -94,20 +94,20 @@ func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // View renders the message component based on its current state. // Returns different views for spinning, user, and assistant messages. -func (m *messageCmp) View() string { +func (m *messageCmp) View() tea.View { if m.spinning { - return m.style().PaddingLeft(1).Render(m.anim.View()) + return tea.NewView(m.style().PaddingLeft(1).Render(m.anim.View().String())) } if m.message.ID != "" { // this is a user or assistant message switch m.message.Role { case message.User: - return m.renderUserMessage() + return tea.NewView(m.renderUserMessage()) default: - return m.renderAssistantMessage() + return tea.NewView(m.renderAssistantMessage()) } } - return "Unknown Message" + return tea.NewView(m.style().Render("No message content")) } // GetMessage returns the underlying message data diff --git a/internal/tui/components/chat/messages/renderer.go b/internal/tui/components/chat/messages/renderer.go index e559ff11f1c2345f61733ae21b4f1c1a2035f65f..3c86c2be1c063f45f36ac2cdd84b600da820856a 100644 --- a/internal/tui/components/chat/messages/renderer.go +++ b/internal/tui/components/chat/messages/renderer.go @@ -507,7 +507,7 @@ func (tr agentRenderer) Render(v *toolCallCmp) string { } if v.result.ToolCallID == "" { v.spinning = true - parts = append(parts, v.anim.View()) + parts = append(parts, v.anim.View().String()) } else { v.spinning = false } diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go index 333f85864c3a5ae48e7cf8ff8303dbb587c43d39..2f23146a26ba204f895bcac9ee61cb8360f91e2b 100644 --- a/internal/tui/components/chat/messages/tool.go +++ b/internal/tui/components/chat/messages/tool.go @@ -134,22 +134,22 @@ func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // View renders the tool call component based on its current state. // Shows either a pending animation or the tool-specific rendered result. -func (m *toolCallCmp) View() string { +func (m *toolCallCmp) View() tea.View { box := m.style() if !m.call.Finished && !m.cancelled { if m.isNested { - return box.Render(m.renderPending()) + return tea.NewView(box.Render(m.renderPending())) } - return box.PaddingLeft(1).Render(m.renderPending()) + return tea.NewView(box.PaddingLeft(1).Render(m.renderPending())) } r := registry.lookup(m.call.Name) if m.isNested { - return box.Render(r.Render(m)) + return tea.NewView(box.Render(r.Render(m))) } - return box.PaddingLeft(1).Render(r.Render(m)) + return tea.NewView(box.PaddingLeft(1).Render(r.Render(m))) } // State management methods diff --git a/internal/tui/components/chat/sidebar.go b/internal/tui/components/chat/sidebar.go index ce643d20076cd0f28c43dd18b10c47fea09facd9..5d631364a7402f05e2233d28d244c86a0398a3e7 100644 --- a/internal/tui/components/chat/sidebar.go +++ b/internal/tui/components/chat/sidebar.go @@ -82,26 +82,28 @@ func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } -func (m *sidebarCmp) View() string { +func (m *sidebarCmp) View() tea.View { baseStyle := styles.BaseStyle() - return baseStyle. - Width(m.width). - PaddingLeft(4). - PaddingRight(2). - Height(m.height - 1). - Render( - lipgloss.JoinVertical( - lipgloss.Top, - header(), - " ", - m.sessionSection(), - " ", - lspsConfigured(), - " ", - m.modifiedFiles(), + return tea.NewView( + baseStyle. + Width(m.width). + PaddingLeft(4). + PaddingRight(2). + Height(m.height - 1). + Render( + lipgloss.JoinVertical( + lipgloss.Top, + header(), + " ", + m.sessionSection(), + " ", + lspsConfigured(), + " ", + m.modifiedFiles(), + ), ), - ) + ) } func (m *sidebarCmp) sessionSection() string { diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go index 341b94b1ca5998f974f60f2d0a922ec1b16bd6f7..1dd00a01b30a890dd123a670e8ceb4cf4277a3fd 100644 --- a/internal/tui/components/core/list/list.go +++ b/internal/tui/components/core/list/list.go @@ -269,14 +269,19 @@ func (m *model) scrollUp(amount int) { // View renders the list to a string for display. // Returns empty string if the list has no dimensions. // Triggers re-rendering if needed before returning content. -func (m *model) View() string { +func (m *model) View() tea.View { if m.viewState.height == 0 || m.viewState.width == 0 { - return "" + return tea.NewView("") // No content to display } if m.renderState.needsRerender { m.renderVisible() } - return lipgloss.NewStyle().Padding(m.padding...).Height(m.viewState.height).Render(m.viewState.content) + return tea.NewView( + lipgloss.NewStyle(). + Padding(m.padding...). + Height(m.viewState.height). + Render(m.viewState.content), + ) } // Items returns a copy of all items in the list. @@ -642,7 +647,7 @@ func (m *model) rerenderItem(inx int) { // getItemLines converts an item to its rendered lines, including any gap spacing. func (m *model) getItemLines(item util.Model) []string { - itemLines := strings.Split(item.View(), "\n") + itemLines := strings.Split(item.View().String(), "\n") if m.gapSize > 0 { gap := make([]string, m.gapSize) itemLines = append(itemLines, gap...) diff --git a/internal/tui/components/core/status.go b/internal/tui/components/core/status.go index 037bd7417897a75f62feb16eca90b81a82360648..9d01d835c1a581aa52de241b4598efc8e3171d09 100644 --- a/internal/tui/components/core/status.go +++ b/internal/tui/components/core/status.go @@ -9,6 +9,7 @@ import ( "github.com/charmbracelet/lipgloss/v2" "github.com/opencode-ai/opencode/internal/config" "github.com/opencode-ai/opencode/internal/llm/models" + "github.com/opencode-ai/opencode/internal/logging" "github.com/opencode-ai/opencode/internal/lsp" "github.com/opencode-ai/opencode/internal/lsp/protocol" "github.com/opencode-ai/opencode/internal/pubsub" @@ -47,6 +48,8 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width return m, nil + + // Handle sesson messages case chat.SessionSelectedMsg: m.session = msg case chat.SessionClearedMsg: @@ -57,6 +60,8 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.session = msg.Payload } } + + // Handle status info case util.InfoMsg: m.info = msg ttl := msg.TTL @@ -66,6 +71,37 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.clearMessageCmd(ttl) case util.ClearStatusMsg: m.info = util.InfoMsg{} + + // Handle persistant logs + case pubsub.Event[logging.LogMessage]: + if msg.Payload.Persist { + switch msg.Payload.Level { + case "error": + m.info = util.InfoMsg{ + Type: util.InfoTypeError, + Msg: msg.Payload.Message, + TTL: msg.Payload.PersistTime, + } + case "info": + m.info = util.InfoMsg{ + Type: util.InfoTypeInfo, + Msg: msg.Payload.Message, + TTL: msg.Payload.PersistTime, + } + case "warn": + m.info = util.InfoMsg{ + Type: util.InfoTypeWarn, + Msg: msg.Payload.Message, + TTL: msg.Payload.PersistTime, + } + default: + m.info = util.InfoMsg{ + Type: util.InfoTypeInfo, + Msg: msg.Payload.Message, + TTL: msg.Payload.PersistTime, + } + } + } } return m, nil } @@ -116,7 +152,7 @@ func formatTokensAndCost(tokens, contextWindow int64, cost float64) string { return fmt.Sprintf("Context: %s, Cost: %s", formattedTokens, formattedCost) } -func (m statusCmp) View() string { +func (m statusCmp) View() tea.View { t := theme.CurrentTheme() modelID := config.Get().Agents[config.AgentCoder].Model model := models.SupportedModels[modelID] @@ -176,7 +212,7 @@ func (m statusCmp) View() string { status += diagnostics status += m.model() - return status + return tea.NewView(status) } func (m *statusCmp) projectDiagnostics() string { diff --git a/internal/tui/components/dialog/arguments.go b/internal/tui/components/dialog/arguments.go index 109a389954b351c31e81b2e034c134202d4d7e0f..5c289ddd25bd44f6d4ae070eef73b995ed3fd00b 100644 --- a/internal/tui/components/dialog/arguments.go +++ b/internal/tui/components/dialog/arguments.go @@ -73,12 +73,13 @@ func NewMultiArgumentsDialogCmp(commandID, content string, argNames []string) Mu ti.Placeholder = fmt.Sprintf("Enter value for %s...", name) ti.SetWidth(40) ti.Prompt = "" - ti.Styles.Focused.Placeholder = ti.Styles.Focused.Placeholder.Background(t.Background()) - ti.Styles.Blurred.Placeholder = ti.Styles.Blurred.Placeholder.Background(t.Background()) - ti.Styles.Focused.Suggestion = ti.Styles.Focused.Suggestion.Background(t.Background()).Foreground(t.Primary()) - ti.Styles.Blurred.Suggestion = ti.Styles.Blurred.Suggestion.Background(t.Background()) - ti.Styles.Focused.Text = ti.Styles.Focused.Text.Background(t.Background()).Foreground(t.Primary()) - ti.Styles.Blurred.Text = ti.Styles.Blurred.Text.Background(t.Background()) + styles := ti.Styles() + styles.Focused.Placeholder = styles.Focused.Placeholder.Background(t.Background()) + styles.Blurred.Placeholder = styles.Blurred.Placeholder.Background(t.Background()) + styles.Focused.Suggestion = styles.Focused.Suggestion.Background(t.Background()).Foreground(t.Primary()) + styles.Blurred.Suggestion = styles.Blurred.Suggestion.Background(t.Background()) + styles.Focused.Text = styles.Focused.Text.Background(t.Background()).Foreground(t.Primary()) + styles.Blurred.Text = styles.Blurred.Text.Background(t.Background()) // Only focus the first input initially if i == 0 { diff --git a/internal/tui/components/dialog/commands.go b/internal/tui/components/dialog/commands.go index c89e8ffa1f2af7908651104cd021a2ea5ebed6c9..1e60d3ed1f317c2729ce33ae3e62f5867a2245e6 100644 --- a/internal/tui/components/dialog/commands.go +++ b/internal/tui/components/dialog/commands.go @@ -114,7 +114,7 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return c, tea.Batch(cmds...) } -func (c *commandDialogCmp) View() string { +func (c *commandDialogCmp) View() tea.View { t := theme.CurrentTheme() baseStyle := styles.BaseStyle() @@ -146,16 +146,18 @@ func (c *commandDialogCmp) View() string { lipgloss.Left, title, baseStyle.Width(maxWidth).Render(""), - baseStyle.Width(maxWidth).Render(c.listView.View()), + baseStyle.Width(maxWidth).Render(c.listView.View().String()), baseStyle.Width(maxWidth).Render(""), ) - return baseStyle.Padding(1, 2). - Border(lipgloss.RoundedBorder()). - BorderBackground(t.Background()). - BorderForeground(t.TextMuted()). - Width(lipgloss.Width(content) + 4). - Render(content) + return tea.NewView( + baseStyle.Padding(1, 2). + Border(lipgloss.RoundedBorder()). + BorderBackground(t.Background()). + BorderForeground(t.TextMuted()). + Width(lipgloss.Width(content) + 4). + Render(content), + ) } func (c *commandDialogCmp) BindingKeys() []key.Binding { diff --git a/internal/tui/components/dialog/complete.go b/internal/tui/components/dialog/complete.go index 4fbd6e3eee9338ca79f02f90e3e111cbf1339541..d5cf1519a91c1cd5c6e3572cb33f43f84d64b7e6 100644 --- a/internal/tui/components/dialog/complete.go +++ b/internal/tui/components/dialog/complete.go @@ -202,7 +202,7 @@ func (c *completionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return c, tea.Batch(cmds...) } -func (c *completionDialogCmp) View() string { +func (c *completionDialogCmp) View() tea.View { t := theme.CurrentTheme() baseStyle := styles.BaseStyle() @@ -219,15 +219,17 @@ func (c *completionDialogCmp) View() string { c.listView.SetMaxWidth(maxWidth) - return baseStyle.Padding(0, 0). - Border(lipgloss.NormalBorder()). - BorderBottom(false). - BorderRight(false). - BorderLeft(false). - BorderBackground(t.Background()). - BorderForeground(t.TextMuted()). - Width(c.width). - Render(c.listView.View()) + return tea.NewView( + baseStyle.Padding(0, 0). + Border(lipgloss.NormalBorder()). + BorderBottom(false). + BorderRight(false). + BorderLeft(false). + BorderBackground(t.Background()). + BorderForeground(t.TextMuted()). + Width(c.width). + Render(c.listView.View().String()), + ) } func (c *completionDialogCmp) SetWidth(width int) { diff --git a/internal/tui/components/dialog/filepicker.go b/internal/tui/components/dialog/filepicker.go index 1b09d53a56542255fd83248cdc1b39ebbb2db24e..8edb182701a6294521098550e40ee661e727d919 100644 --- a/internal/tui/components/dialog/filepicker.go +++ b/internal/tui/components/dialog/filepicker.go @@ -258,7 +258,7 @@ func (f *filepickerCmp) addAttachmentToMessage() (tea.Model, tea.Cmd) { return f, util.CmdHandler(AttachmentAddedMsg{attachment}) } -func (f *filepickerCmp) View() string { +func (f *filepickerCmp) View() tea.View { t := theme.CurrentTheme() const maxVisibleDirs = 20 const maxWidth = 80 @@ -349,7 +349,9 @@ func (f *filepickerCmp) View() string { BorderForeground(t.TextMuted()). Width(lipgloss.Width(content) + 4) - return lipgloss.JoinHorizontal(lipgloss.Center, contentStyle.Render(content), viewportstyle) + return tea.NewView( + lipgloss.JoinHorizontal(lipgloss.Center, contentStyle.Render(content), viewportstyle), + ) } type FilepickerCmp interface { diff --git a/internal/tui/components/dialog/help.go b/internal/tui/components/dialog/help.go index 63416f4250548c51cce14308b6efac617221a2c2..549c2a476fa43da14a36c5d942e894d83b6f79ac 100644 --- a/internal/tui/components/dialog/help.go +++ b/internal/tui/components/dialog/help.go @@ -166,7 +166,7 @@ func (h *helpCmp) render() string { return content } -func (h *helpCmp) View() string { +func (h *helpCmp) View() tea.View { t := theme.CurrentTheme() baseStyle := styles.BaseStyle() @@ -177,18 +177,20 @@ func (h *helpCmp) View() string { Foreground(t.Primary()). Render("Keyboard Shortcuts") - return baseStyle.Padding(1). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.TextMuted()). - Width(h.width). - BorderBackground(t.Background()). - Render( - lipgloss.JoinVertical(lipgloss.Center, - header, - baseStyle.Render(strings.Repeat(" ", lipgloss.Width(header))), - content, + return tea.NewView( + baseStyle.Padding(1). + Border(lipgloss.RoundedBorder()). + BorderForeground(t.TextMuted()). + Width(h.width). + BorderBackground(t.Background()). + Render( + lipgloss.JoinVertical(lipgloss.Center, + header, + baseStyle.Render(strings.Repeat(" ", lipgloss.Width(header))), + content, + ), ), - ) + ) } type HelpCmp interface { diff --git a/internal/tui/components/dialog/models.go b/internal/tui/components/dialog/models.go index 25b45839754bae91402e8a8cff529b55b9e6f6a6..d14ffbf9241d749095aa9a03a8e00b33489ba4b4 100644 --- a/internal/tui/components/dialog/models.go +++ b/internal/tui/components/dialog/models.go @@ -185,7 +185,7 @@ func (m *modelDialogCmp) switchProvider(offset int) { m.setupModelsForProvider(m.provider) } -func (m *modelDialogCmp) View() string { +func (m *modelDialogCmp) View() tea.View { t := theme.CurrentTheme() baseStyle := styles.BaseStyle() @@ -220,12 +220,14 @@ func (m *modelDialogCmp) View() string { scrollIndicator, ) - return baseStyle.Padding(1, 2). - Border(lipgloss.RoundedBorder()). - BorderBackground(t.Background()). - BorderForeground(t.TextMuted()). - Width(lipgloss.Width(content) + 4). - Render(content) + return tea.NewView( + baseStyle.Padding(1, 2). + Border(lipgloss.RoundedBorder()). + BorderBackground(t.Background()). + BorderForeground(t.TextMuted()). + Width(lipgloss.Width(content) + 4). + Render(content), + ) } func (m *modelDialogCmp) getScrollIndicators(maxWidth int) string { diff --git a/internal/tui/components/dialog/permission.go b/internal/tui/components/dialog/permission.go index 3db2ea2125e37a58e68a8f9eab8ee65257fb2c9a..e258dbc24099414309faef78ac8a6aefe204c989 100644 --- a/internal/tui/components/dialog/permission.go +++ b/internal/tui/components/dialog/permission.go @@ -437,8 +437,8 @@ func (p *permissionDialogCmp) render() string { ) } -func (p *permissionDialogCmp) View() string { - return p.render() +func (p *permissionDialogCmp) View() tea.View { + return tea.NewView(p.render()) } func (p *permissionDialogCmp) BindingKeys() []key.Binding { diff --git a/internal/tui/components/dialog/session.go b/internal/tui/components/dialog/session.go index 8fc704711a8017241cc0093f1f7dd22f363c54af..15a118d6efae995182c3cefa1a4b81ddaa0e5e28 100644 --- a/internal/tui/components/dialog/session.go +++ b/internal/tui/components/dialog/session.go @@ -105,17 +105,19 @@ func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return s, nil } -func (s *sessionDialogCmp) View() string { +func (s *sessionDialogCmp) View() tea.View { t := theme.CurrentTheme() baseStyle := styles.BaseStyle() if len(s.sessions) == 0 { - return baseStyle.Padding(1, 2). - Border(lipgloss.RoundedBorder()). - BorderBackground(t.Background()). - BorderForeground(t.TextMuted()). - Width(40). - Render("No sessions available") + return tea.NewView( + baseStyle.Padding(1, 2). + Border(lipgloss.RoundedBorder()). + BorderBackground(t.Background()). + BorderForeground(t.TextMuted()). + Width(40). + Render("No sessions available"), + ) } // Calculate max width needed for session titles @@ -177,11 +179,13 @@ func (s *sessionDialogCmp) View() string { baseStyle.Width(maxWidth).Render(""), ) - return baseStyle.Padding(1, 2). - Border(lipgloss.RoundedBorder()). - BorderBackground(t.Background()). - BorderForeground(t.TextMuted()). - Render(content) + return tea.NewView( + baseStyle.Padding(1, 2). + Border(lipgloss.RoundedBorder()). + BorderBackground(t.Background()). + BorderForeground(t.TextMuted()). + Render(content), + ) } func (s *sessionDialogCmp) BindingKeys() []key.Binding { diff --git a/internal/tui/components/dialog/theme.go b/internal/tui/components/dialog/theme.go index bdd89a9dd82dc31040a19027535b9ca914263124..c5faf6c902d6bdf2f935abb2418d3adb4558def9 100644 --- a/internal/tui/components/dialog/theme.go +++ b/internal/tui/components/dialog/theme.go @@ -122,17 +122,19 @@ func (t *themeDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return t, nil } -func (t *themeDialogCmp) View() string { +func (t *themeDialogCmp) View() tea.View { currentTheme := theme.CurrentTheme() baseStyle := styles.BaseStyle() if len(t.themes) == 0 { - return baseStyle.Padding(1, 2). - Border(lipgloss.RoundedBorder()). - BorderBackground(currentTheme.Background()). - BorderForeground(currentTheme.TextMuted()). - Width(40). - Render("No themes available") + return tea.NewView( + baseStyle.Padding(1, 2). + Border(lipgloss.RoundedBorder()). + BorderBackground(currentTheme.Background()). + BorderForeground(currentTheme.TextMuted()). + Width(40). + Render("No themes available"), + ) } // Calculate max width needed for theme names @@ -175,12 +177,14 @@ func (t *themeDialogCmp) View() string { baseStyle.Width(maxWidth).Render(""), ) - return baseStyle.Padding(1, 2). - Border(lipgloss.RoundedBorder()). - BorderBackground(currentTheme.Background()). - BorderForeground(currentTheme.TextMuted()). - Width(lipgloss.Width(content) + 4). - Render(content) + return tea.NewView( + baseStyle.Padding(1, 2). + Border(lipgloss.RoundedBorder()). + BorderBackground(currentTheme.Background()). + BorderForeground(currentTheme.TextMuted()). + Width(lipgloss.Width(content) + 4). + Render(content), + ) } func (t *themeDialogCmp) BindingKeys() []key.Binding { diff --git a/internal/tui/components/dialogs/commands/arguments.go b/internal/tui/components/dialogs/commands/arguments.go new file mode 100644 index 0000000000000000000000000000000000000000..02ecf747c56aa93c8b65763d2931be2030e5975b --- /dev/null +++ b/internal/tui/components/dialogs/commands/arguments.go @@ -0,0 +1,16 @@ +package commands + +// ShowArgumentsDialogMsg is a message that is sent to show the arguments dialog. +type ShowArgumentsDialogMsg struct { + CommandID string + Content string + ArgNames []string +} + +// CloseArgumentsDialogMsg is a message that is sent when the arguments dialog is closed. +type CloseArgumentsDialogMsg struct { + Submit bool + CommandID string + Content string + Args map[string]string +} diff --git a/internal/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go new file mode 100644 index 0000000000000000000000000000000000000000..7a34fdd511bee2ade344ae44ca3652e0cecbe2c3 --- /dev/null +++ b/internal/tui/components/dialogs/commands/commands.go @@ -0,0 +1,135 @@ +package commands + +import ( + "github.com/charmbracelet/bubbles/v2/textinput" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/lipgloss/v2" + + "github.com/opencode-ai/opencode/internal/logging" + "github.com/opencode-ai/opencode/internal/tui/components/core/list" + "github.com/opencode-ai/opencode/internal/tui/components/dialogs" + "github.com/opencode-ai/opencode/internal/tui/styles" + "github.com/opencode-ai/opencode/internal/tui/theme" + "github.com/opencode-ai/opencode/internal/tui/util" +) + +const ( + id dialogs.DialogID = "commands" +) + +// Command represents a command that can be executed +type Command struct { + ID string + Title string + Description string + Handler func(cmd Command) tea.Cmd +} + +// CommandsDialog represents the commands dialog. +type CommandsDialog interface { + dialogs.DialogModel +} + +type commandDialogCmp struct { + width int + wWidth int // Width of the terminal window + wHeight int // Height of the terminal window + + commandList list.ListModel + input textinput.Model + oldCursor tea.Cursor +} + +func NewCommandDialog() CommandsDialog { + ti := textinput.New() + ti.Placeholder = "Type a command or search..." + ti.SetVirtualCursor(false) + ti.Focus() + ti.SetWidth(60 - 7) + commandList := list.New() + return &commandDialogCmp{ + commandList: commandList, + width: 60, + input: ti, + } +} + +func (c *commandDialogCmp) Init() tea.Cmd { + logging.Info("Initializing commands dialog") + commands, err := LoadCustomCommands() + if err != nil { + return util.ReportError(err) + } + logging.Info("Commands loaded", "count", len(commands)) + + commandItems := make([]util.Model, 0, len(commands)) + + for _, cmd := range commands { + commandItems = append(commandItems, NewCommandItem(cmd)) + } + c.commandList.SetItems(commandItems) + return c.commandList.Init() +} + +func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + c.wWidth = msg.Width + c.wHeight = msg.Height + return c, c.commandList.SetSize(60, min(len(c.commandList.Items())*2, c.wHeight/2)) + } + u, cmd := c.input.Update(msg) + c.input = u + return c, cmd +} + +func (c *commandDialogCmp) View() tea.View { + content := lipgloss.JoinVertical( + lipgloss.Left, + c.inputStyle().Render(c.input.View()), + c.commandList.View().String(), + ) + + v := tea.NewView(c.style().Render(content)) + v.SetCursor(c.getCursor()) + return v +} + +func (c *commandDialogCmp) getCursor() *tea.Cursor { + cursor := c.input.Cursor() + offset := 10 + 1 + cursor.Y += offset + _, col := c.Position() + cursor.X = c.input.Cursor().X + col + 2 + return cursor +} + +func (c *commandDialogCmp) inputStyle() lipgloss.Style { + t := theme.CurrentTheme() + return styles.BaseStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(t.TextMuted()). + BorderBackground(t.Background()). + BorderBottom(true) +} + +func (c *commandDialogCmp) style() lipgloss.Style { + t := theme.CurrentTheme() + return styles.BaseStyle(). + Width(c.width). + Padding(0, 1, 1, 1). + Border(lipgloss.RoundedBorder()). + BorderBackground(t.Background()). + BorderForeground(t.TextMuted()) +} + +func (q *commandDialogCmp) Position() (int, int) { + row := 10 + col := q.wWidth / 2 + col -= q.width / 2 + return row, col +} + +func (c *commandDialogCmp) ID() dialogs.DialogID { + return id +} diff --git a/internal/tui/components/dialogs/commands/item.go b/internal/tui/components/dialogs/commands/item.go new file mode 100644 index 0000000000000000000000000000000000000000..36c00199da323abc7038478079f353c1c2279820 --- /dev/null +++ b/internal/tui/components/dialogs/commands/item.go @@ -0,0 +1,55 @@ +package commands + +import ( + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/opencode-ai/opencode/internal/tui/layout" + "github.com/opencode-ai/opencode/internal/tui/util" +) + +type CommandItem interface { + util.Model + layout.Focusable +} + +type commandItem struct { + command Command + focus bool +} + +func NewCommandItem(command Command) CommandItem { + return &commandItem{ + command: command, + } +} + +// Init implements CommandItem. +func (c *commandItem) Init() tea.Cmd { + return nil +} + +// Update implements CommandItem. +func (c *commandItem) Update(tea.Msg) (tea.Model, tea.Cmd) { + return c, nil +} + +// View implements CommandItem. +func (c *commandItem) View() tea.View { + return tea.NewView(c.command.Title) +} + +// Blur implements CommandItem. +func (c *commandItem) Blur() tea.Cmd { + c.focus = false + return nil +} + +// Focus implements CommandItem. +func (c *commandItem) Focus() tea.Cmd { + c.focus = true + return nil +} + +// IsFocused implements CommandItem. +func (c *commandItem) IsFocused() bool { + return c.focus +} diff --git a/internal/tui/components/dialogs/commands/loader.go b/internal/tui/components/dialogs/commands/loader.go new file mode 100644 index 0000000000000000000000000000000000000000..8767d6bf7b0c3a3e901dcdebd029cc29d7da4ed6 --- /dev/null +++ b/internal/tui/components/dialogs/commands/loader.go @@ -0,0 +1,204 @@ +package commands + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/opencode-ai/opencode/internal/config" + "github.com/opencode-ai/opencode/internal/tui/util" +) + +const ( + UserCommandPrefix = "user:" + ProjectCommandPrefix = "project:" +) + +var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`) + +type commandLoader struct { + sources []commandSource +} + +type commandSource struct { + path string + prefix string +} + +func LoadCustomCommands() ([]Command, error) { + cfg := config.Get() + if cfg == nil { + return nil, fmt.Errorf("config not loaded") + } + + loader := &commandLoader{ + sources: buildCommandSources(cfg), + } + + return loader.loadAll() +} + +func buildCommandSources(cfg *config.Config) []commandSource { + var sources []commandSource + + // XDG config directory + if dir := getXDGCommandsDir(); dir != "" { + sources = append(sources, commandSource{ + path: dir, + prefix: UserCommandPrefix, + }) + } + + // Home directory + if home, err := os.UserHomeDir(); err == nil { + sources = append(sources, commandSource{ + path: filepath.Join(home, ".opencode", "commands"), + prefix: UserCommandPrefix, + }) + } + + // Project directory + sources = append(sources, commandSource{ + path: filepath.Join(cfg.Data.Directory, "commands"), + prefix: ProjectCommandPrefix, + }) + + return sources +} + +func getXDGCommandsDir() string { + xdgHome := os.Getenv("XDG_CONFIG_HOME") + if xdgHome == "" { + if home, err := os.UserHomeDir(); err == nil { + xdgHome = filepath.Join(home, ".config") + } + } + if xdgHome != "" { + return filepath.Join(xdgHome, "opencode", "commands") + } + return "" +} + +func (l *commandLoader) loadAll() ([]Command, error) { + var commands []Command + + for _, source := range l.sources { + if cmds, err := l.loadFromSource(source); err == nil { + commands = append(commands, cmds...) + } + } + + return commands, nil +} + +func (l *commandLoader) loadFromSource(source commandSource) ([]Command, error) { + if err := ensureDir(source.path); err != nil { + return nil, err + } + + var commands []Command + + err := filepath.WalkDir(source.path, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || !isMarkdownFile(d.Name()) { + return err + } + + cmd, err := l.loadCommand(path, source.path, source.prefix) + if err != nil { + return nil // Skip invalid files + } + + commands = append(commands, cmd) + return nil + }) + + return commands, err +} + +func (l *commandLoader) loadCommand(path, baseDir, prefix string) (Command, error) { + content, err := os.ReadFile(path) + if err != nil { + return Command{}, err + } + + id := buildCommandID(path, baseDir, prefix) + + return Command{ + ID: id, + Title: id, + Description: fmt.Sprintf("Custom command from %s", filepath.Base(path)), + Handler: createCommandHandler(id, string(content)), + }, nil +} + +func buildCommandID(path, baseDir, prefix string) string { + relPath, _ := filepath.Rel(baseDir, path) + parts := strings.Split(relPath, string(filepath.Separator)) + + // Remove .md extension from last part + if len(parts) > 0 { + lastIdx := len(parts) - 1 + parts[lastIdx] = strings.TrimSuffix(parts[lastIdx], filepath.Ext(parts[lastIdx])) + } + + return prefix + strings.Join(parts, ":") +} + +func createCommandHandler(id string, content string) func(Command) tea.Cmd { + return func(cmd Command) tea.Cmd { + args := extractArgNames(content) + + if len(args) > 0 { + return util.CmdHandler(ShowArgumentsDialogMsg{ + CommandID: id, + Content: content, + ArgNames: args, + }) + } + + return util.CmdHandler(CommandRunCustomMsg{ + Content: content, + Args: nil, + }) + } +} + +func extractArgNames(content string) []string { + matches := namedArgPattern.FindAllStringSubmatch(content, -1) + if len(matches) == 0 { + return nil + } + + seen := make(map[string]bool) + var args []string + + for _, match := range matches { + arg := match[1] + if !seen[arg] { + seen[arg] = true + args = append(args, arg) + } + } + + return args +} + +func ensureDir(path string) error { + if _, err := os.Stat(path); os.IsNotExist(err) { + return os.MkdirAll(path, 0755) + } + return nil +} + +func isMarkdownFile(name string) bool { + return strings.HasSuffix(strings.ToLower(name), ".md") +} + +type CommandRunCustomMsg struct { + Content string + Args map[string]string +} diff --git a/internal/tui/components/dialogs/dialogs.go b/internal/tui/components/dialogs/dialogs.go new file mode 100644 index 0000000000000000000000000000000000000000..9862388fc16af59b0dc3ac63a8485cc02924370d --- /dev/null +++ b/internal/tui/components/dialogs/dialogs.go @@ -0,0 +1,164 @@ +package dialogs + +import ( + "slices" + + "github.com/charmbracelet/bubbles/v2/key" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/lipgloss/v2" + "github.com/opencode-ai/opencode/internal/tui/util" +) + +type DialogID string + +// DialogModel represents a dialog component that can be displayed. +type DialogModel interface { + util.Model + Position() (int, int) + ID() DialogID +} + +// CloseCallback allows dialogs to perform cleanup when closed. +type CloseCallback interface { + Close() tea.Cmd +} + +// AbsolutePositionable is an interface for components that can set their position +type AbsolutePositionable interface { + SetPosition(x, y int) +} + +// OpenDialogMsg is sent to open a new dialog with specified dimensions. +type OpenDialogMsg struct { + Model DialogModel +} + +// CloseDialogMsg is sent to close the topmost dialog. +type CloseDialogMsg struct{} + +// DialogCmp manages a stack of dialogs with keyboard navigation. +type DialogCmp interface { + tea.Model + + Dialogs() []DialogModel + HasDialogs() bool + GetLayers() []*lipgloss.Layer + ActiveView() *tea.View +} + +type dialogCmp struct { + width, height int + dialogs []DialogModel + idMap map[DialogID]int + keymap KeyMap +} + +// NewDialogCmp creates a new dialog manager. +func NewDialogCmp() DialogCmp { + return dialogCmp{ + dialogs: []DialogModel{}, + keymap: DefaultKeymap(), + idMap: make(map[DialogID]int), + } +} + +func (d dialogCmp) Init() tea.Cmd { + return nil +} + +// Update handles dialog lifecycle and forwards messages to the active dialog. +func (d dialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + var cmds []tea.Cmd + d.width = msg.Width + d.height = msg.Height + for i := range d.dialogs { + u, cmd := d.dialogs[i].Update(msg) + d.dialogs[i] = u.(DialogModel) + cmds = append(cmds, cmd) + } + return d, tea.Batch(cmds...) + case OpenDialogMsg: + return d.handleOpen(msg) + case CloseDialogMsg: + if len(d.dialogs) == 0 { + return d, nil + } + inx := len(d.dialogs) - 1 + dialog := d.dialogs[inx] + delete(d.idMap, dialog.ID()) + d.dialogs = d.dialogs[:len(d.dialogs)-1] + if closeable, ok := dialog.(CloseCallback); ok { + return d, closeable.Close() + } + return d, nil + case tea.KeyPressMsg: + if key.Matches(msg, d.keymap.Close) { + return d, util.CmdHandler(CloseDialogMsg{}) + } + } + if d.HasDialogs() { + lastIndex := len(d.dialogs) - 1 + u, cmd := d.dialogs[lastIndex].Update(msg) + d.dialogs[lastIndex] = u.(DialogModel) + return d, cmd + } + return d, nil +} + +func (d dialogCmp) handleOpen(msg OpenDialogMsg) (tea.Model, tea.Cmd) { + if d.HasDialogs() { + dialog := d.dialogs[len(d.dialogs)-1] + if dialog.ID() == msg.Model.ID() { + return d, nil // Do not open a dialog if it's already the topmost one + } + if dialog.ID() == "quit" { + return d, nil // Do not open dialogs ontop of quit + } + } + // if the dialog is already in thel stack make it the last item + if _, ok := d.idMap[msg.Model.ID()]; ok { + existing := d.dialogs[d.idMap[msg.Model.ID()]] + // Reuse the model so we keep the state + msg.Model = existing + d.dialogs = slices.Delete(d.dialogs, d.idMap[msg.Model.ID()], d.idMap[msg.Model.ID()]+1) + } + d.idMap[msg.Model.ID()] = len(d.dialogs) + d.dialogs = append(d.dialogs, msg.Model) + var cmds []tea.Cmd + cmd := msg.Model.Init() + cmds = append(cmds, cmd) + _, cmd = msg.Model.Update(tea.WindowSizeMsg{ + Width: d.width, + Height: d.height, + }) + cmds = append(cmds, cmd) + return d, tea.Batch(cmds...) +} + +func (d dialogCmp) Dialogs() []DialogModel { + return d.dialogs +} + +func (d dialogCmp) ActiveView() *tea.View { + if len(d.dialogs) == 0 { + return nil + } + view := d.dialogs[len(d.dialogs)-1].View() + return &view +} + +func (d dialogCmp) GetLayers() []*lipgloss.Layer { + layers := []*lipgloss.Layer{} + for _, dialog := range d.Dialogs() { + dialogView := dialog.View().String() + row, col := dialog.Position() + layers = append(layers, lipgloss.NewLayer(dialogView).X(col).Y(row)) + } + return layers +} + +func (d dialogCmp) HasDialogs() bool { + return len(d.dialogs) > 0 +} diff --git a/internal/tui/components/dialogs/keys.go b/internal/tui/components/dialogs/keys.go new file mode 100644 index 0000000000000000000000000000000000000000..34a5aeb4d5b46b52e4ef6968e5c8bc480a2e3819 --- /dev/null +++ b/internal/tui/components/dialogs/keys.go @@ -0,0 +1,37 @@ +package dialogs + +import ( + "github.com/charmbracelet/bubbles/v2/key" + "github.com/opencode-ai/opencode/internal/tui/layout" +) + +// KeyMap defines keyboard bindings for dialog management. +type KeyMap struct { + Close key.Binding +} + +func DefaultKeymap() KeyMap { + return KeyMap{ + Close: key.NewBinding( + key.WithKeys("esc"), + ), + } +} + +// FullHelp implements help.KeyMap. +func (k KeyMap) FullHelp() [][]key.Binding { + m := [][]key.Binding{} + slice := layout.KeyMapToSlice(k) + for i := 0; i < len(slice); i += 4 { + end := min(i+4, len(slice)) + m = append(m, slice[i:end]) + } + return m +} + +// ShortHelp implements help.KeyMap. +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{ + k.Close, + } +} diff --git a/internal/tui/components/dialogs/quit/keys.go b/internal/tui/components/dialogs/quit/keys.go new file mode 100644 index 0000000000000000000000000000000000000000..a2459af696d16ed497565b71775887d7f75f317d --- /dev/null +++ b/internal/tui/components/dialogs/quit/keys.go @@ -0,0 +1,59 @@ +package quit + +import ( + "github.com/charmbracelet/bubbles/v2/key" + "github.com/opencode-ai/opencode/internal/tui/layout" +) + +// KeyMap defines the keyboard bindings for the quit dialog. +type KeyMap struct { + LeftRight key.Binding + EnterSpace key.Binding + Yes key.Binding + No key.Binding + Tab key.Binding +} + +func DefaultKeymap() KeyMap { + return KeyMap{ + LeftRight: key.NewBinding( + key.WithKeys("left", "right"), + key.WithHelp("←/→", "switch options"), + ), + EnterSpace: key.NewBinding( + key.WithKeys("enter", " "), + key.WithHelp("enter/space", "confirm"), + ), + Yes: key.NewBinding( + key.WithKeys("y", "Y", "ctrl+c"), + key.WithHelp("y/Y/ctrl+c", "yes"), + ), + No: key.NewBinding( + key.WithKeys("n", "N"), + key.WithHelp("n/N", "no"), + ), + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "switch options"), + ), + } +} + +// FullHelp implements help.KeyMap. +func (k KeyMap) FullHelp() [][]key.Binding { + m := [][]key.Binding{} + slice := layout.KeyMapToSlice(k) + for i := 0; i < len(slice); i += 4 { + end := min(i+4, len(slice)) + m = append(m, slice[i:end]) + } + return m +} + +// ShortHelp implements help.KeyMap. +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{ + k.LeftRight, + k.EnterSpace, + } +} diff --git a/internal/tui/components/dialog/quit.go b/internal/tui/components/dialogs/quit/quit.go similarity index 51% rename from internal/tui/components/dialog/quit.go rename to internal/tui/components/dialogs/quit/quit.go index c1c7a5b1441eec5d2529f2aeafd4f068bd47711c..211bac3c88258dae43b791080e6570b58192b5db 100644 --- a/internal/tui/components/dialog/quit.go +++ b/internal/tui/components/dialogs/quit/quit.go @@ -1,87 +1,74 @@ -package dialog +package quit import ( - "strings" - "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" + "github.com/opencode-ai/opencode/internal/tui/components/dialogs" "github.com/opencode-ai/opencode/internal/tui/layout" "github.com/opencode-ai/opencode/internal/tui/styles" "github.com/opencode-ai/opencode/internal/tui/theme" "github.com/opencode-ai/opencode/internal/tui/util" ) -const question = "Are you sure you want to quit?" - -type CloseQuitMsg struct{} +const ( + question = "Are you sure you want to quit?" + id dialogs.DialogID = "quit" +) +// QuitDialog represents a confirmation dialog for quitting the application. type QuitDialog interface { - util.Model + dialogs.DialogModel layout.Bindings } type quitDialogCmp struct { - selectedNo bool -} + wWidth int + wHeight int -type helpMapping struct { - LeftRight key.Binding - EnterSpace key.Binding - Yes key.Binding - No key.Binding - Tab key.Binding + selectedNo bool // true if "No" button is selected + keymap KeyMap } -var helpKeys = helpMapping{ - LeftRight: key.NewBinding( - key.WithKeys("left", "right"), - key.WithHelp("←/→", "switch options"), - ), - EnterSpace: key.NewBinding( - key.WithKeys("enter", " "), - key.WithHelp("enter/space", "confirm"), - ), - Yes: key.NewBinding( - key.WithKeys("y", "Y"), - key.WithHelp("y/Y", "yes"), - ), - No: key.NewBinding( - key.WithKeys("n", "N"), - key.WithHelp("n/N", "no"), - ), - Tab: key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "switch options"), - ), +// NewQuitDialog creates a new quit confirmation dialog. +func NewQuitDialog() QuitDialog { + return &quitDialogCmp{ + selectedNo: true, // Default to "No" for safety + keymap: DefaultKeymap(), + } } func (q *quitDialogCmp) Init() tea.Cmd { return nil } +// Update handles keyboard input for the quit dialog. func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case tea.WindowSizeMsg: + q.wWidth = msg.Width + q.wHeight = msg.Height case tea.KeyPressMsg: switch { - case key.Matches(msg, helpKeys.LeftRight) || key.Matches(msg, helpKeys.Tab): + case key.Matches(msg, q.keymap.LeftRight) || key.Matches(msg, q.keymap.Tab): q.selectedNo = !q.selectedNo return q, nil - case key.Matches(msg, helpKeys.EnterSpace): + case key.Matches(msg, q.keymap.EnterSpace): if !q.selectedNo { return q, tea.Quit } - return q, util.CmdHandler(CloseQuitMsg{}) - case key.Matches(msg, helpKeys.Yes): + return q, util.CmdHandler(dialogs.CloseDialogMsg{}) + case key.Matches(msg, q.keymap.Yes): return q, tea.Quit - case key.Matches(msg, helpKeys.No): - return q, util.CmdHandler(CloseQuitMsg{}) + case key.Matches(msg, q.keymap.No): + return q, util.CmdHandler(dialogs.CloseDialogMsg{}) } } return q, nil } -func (q *quitDialogCmp) View() string { +// View renders the quit dialog with Yes/No buttons. +func (q *quitDialogCmp) View() tea.View { t := theme.CurrentTheme() baseStyle := styles.BaseStyle() @@ -100,13 +87,9 @@ func (q *quitDialogCmp) View() string { yesButton := yesStyle.Padding(0, 1).Render("Yes") noButton := noStyle.Padding(0, 1).Render("No") - buttons := lipgloss.JoinHorizontal(lipgloss.Left, yesButton, spacerStyle.Render(" "), noButton) - - width := lipgloss.Width(question) - remainingWidth := width - lipgloss.Width(buttons) - if remainingWidth > 0 { - buttons = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + buttons - } + buttons := baseStyle.Width(lipgloss.Width(question)).Align(lipgloss.Right).Render( + lipgloss.JoinHorizontal(lipgloss.Center, yesButton, spacerStyle.Render(" "), noButton), + ) content := baseStyle.Render( lipgloss.JoinVertical( @@ -123,17 +106,24 @@ func (q *quitDialogCmp) View() string { BorderBackground(t.Background()). BorderForeground(t.TextMuted()) - return quitDialogStyle. - Width(lipgloss.Width(content) + quitDialogStyle.GetHorizontalFrameSize()). - Render(content) + return tea.NewView( + quitDialogStyle.Render(content), + ) } func (q *quitDialogCmp) BindingKeys() []key.Binding { - return layout.KeyMapToSlice(helpKeys) + return layout.KeyMapToSlice(q.keymap) } -func NewQuitCmp() QuitDialog { - return &quitDialogCmp{ - selectedNo: true, - } +func (q *quitDialogCmp) Position() (int, int) { + row := q.wHeight / 2 + row -= 7 / 2 + col := q.wWidth / 2 + col -= (lipgloss.Width(question) + 4) / 2 + + return row, col +} + +func (q *quitDialogCmp) ID() dialogs.DialogID { + return id } diff --git a/internal/tui/components/logs/details.go b/internal/tui/components/logs/details.go index 875395cfe5c535eba8be73a3eccc92f4f666dc53..9ee743ca680eab852fb30a059983bbc5f5427f2c 100644 --- a/internal/tui/components/logs/details.go +++ b/internal/tui/components/logs/details.go @@ -114,8 +114,8 @@ func getLevelStyle(level string) lipgloss.Style { } } -func (i *detailCmp) View() string { - return i.viewport.View() +func (i *detailCmp) View() tea.View { + return tea.NewView(i.viewport.View()) } func (i *detailCmp) GetSize() (int, int) { diff --git a/internal/tui/components/logs/table.go b/internal/tui/components/logs/table.go index 689ea087406cb64be62d06c4beff71fce6483304..791d104bc0e7127136420bcd2519815709f5b79f 100644 --- a/internal/tui/components/logs/table.go +++ b/internal/tui/components/logs/table.go @@ -60,12 +60,12 @@ func (i *tableCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return i, tea.Batch(cmds...) } -func (i *tableCmp) View() string { +func (i *tableCmp) View() tea.View { t := theme.CurrentTheme() defaultStyles := table.DefaultStyles() defaultStyles.Selected = defaultStyles.Selected.Foreground(t.Primary()) i.table.SetStyles(defaultStyles) - return i.table.View() + return tea.NewView(i.table.View()) } func (i *tableCmp) GetSize() (int, int) { diff --git a/internal/tui/components/util/simple-list.go b/internal/tui/components/util/simple-list.go index a944925095a61bc247ff9534bcadff1a1609c01f..36df48394e0792d056deab6380d6a1003cbe6b55 100644 --- a/internal/tui/components/util/simple-list.go +++ b/internal/tui/components/util/simple-list.go @@ -110,7 +110,7 @@ func (c *simpleListCmp[T]) SetMaxWidth(width int) { c.maxWidth = width } -func (c *simpleListCmp[T]) View() string { +func (c *simpleListCmp[T]) View() tea.View { t := theme.CurrentTheme() baseStyle := styles.BaseStyle() @@ -120,11 +120,13 @@ func (c *simpleListCmp[T]) View() string { startIdx := 0 if len(items) <= 0 { - return baseStyle. - Background(t.Background()). - Padding(0, 1). - Width(maxWidth). - Render(c.fallbackMsg) + return tea.NewView( + baseStyle. + Background(t.Background()). + Padding(0, 1). + Width(maxWidth). + Render(c.fallbackMsg), + ) } if len(items) > maxVisibleItems { @@ -146,7 +148,9 @@ func (c *simpleListCmp[T]) View() string { listItems = append(listItems, title) } - return lipgloss.JoinVertical(lipgloss.Left, listItems...) + return tea.NewView( + lipgloss.JoinVertical(lipgloss.Left, listItems...), + ) } func NewSimpleList[T SimpleListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) SimpleList[T] { diff --git a/internal/tui/keys.go b/internal/tui/keys.go new file mode 100644 index 0000000000000000000000000000000000000000..bc836d3dbf1f5bd0e88bc02fb4628e1305f9bcd8 --- /dev/null +++ b/internal/tui/keys.go @@ -0,0 +1,75 @@ +package tui + +import ( + "github.com/charmbracelet/bubbles/v2/key" + "github.com/opencode-ai/opencode/internal/tui/layout" +) + +type KeyMap struct { + Logs key.Binding + Quit key.Binding + Help key.Binding + SwitchSession key.Binding + Commands key.Binding + FilePicker key.Binding + Models key.Binding + SwitchTheme key.Binding +} + +func DefaultKeyMap() KeyMap { + return KeyMap{ + Logs: key.NewBinding( + key.WithKeys("ctrl+l"), + key.WithHelp("ctrl+l", "logs"), + ), + + Quit: key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "quit"), + ), + + Help: key.NewBinding( + key.WithKeys("ctrl+_"), + key.WithHelp("ctrl+?", "toggle help"), + ), + + SwitchSession: key.NewBinding( + key.WithKeys("ctrl+s"), + key.WithHelp("ctrl+s", "switch session"), + ), + + Commands: key.NewBinding( + key.WithKeys("ctrl+k"), + key.WithHelp("ctrl+k", "commands"), + ), + FilePicker: key.NewBinding( + key.WithKeys("ctrl+f"), + key.WithHelp("ctrl+f", "select files to upload"), + ), + Models: key.NewBinding( + key.WithKeys("ctrl+o"), + key.WithHelp("ctrl+o", "model selection"), + ), + + SwitchTheme: key.NewBinding( + key.WithKeys("ctrl+t"), + key.WithHelp("ctrl+t", "switch theme"), + ), + } +} + +// FullHelp implements help.KeyMap. +func (k KeyMap) FullHelp() [][]key.Binding { + m := [][]key.Binding{} + slice := layout.KeyMapToSlice(k) + for i := 0; i < len(slice); i += 4 { + end := min(i+4, len(slice)) + m = append(m, slice[i:end]) + } + return m +} + +// ShortHelp implements help.KeyMap. +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{} +} diff --git a/internal/tui/layout/container.go b/internal/tui/layout/container.go index 81e331c3c27fc2a3fbd3e9010516facce681d683..923f29e6b284086cd00dc52b181d8933d3801eaf 100644 --- a/internal/tui/layout/container.go +++ b/internal/tui/layout/container.go @@ -12,11 +12,14 @@ type Container interface { util.Model Sizeable Bindings + Positionable } type container struct { width int height int + x, y int + content util.Model // Style options @@ -42,7 +45,7 @@ func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return c, cmd } -func (c *container) View() string { +func (c *container) View() tea.View { t := theme.CurrentTheme() style := lipgloss.NewStyle() width := c.width @@ -76,7 +79,10 @@ func (c *container) View() string { PaddingBottom(c.paddingBottom). PaddingLeft(c.paddingLeft) - return style.Render(c.content.View()) + contentView := c.content.View() + view := tea.NewView(style.Render(contentView.String())) + view.SetCursor(contentView.Cursor()) + return view } func (c *container) SetSize(width, height int) tea.Cmd { @@ -115,6 +121,15 @@ func (c *container) GetSize() (int, int) { return c.width, c.height } +func (c *container) SetPosition(x, y int) tea.Cmd { + c.x = x + c.y = y + if positionable, ok := c.content.(Positionable); ok { + return positionable.SetPosition(x, y) + } + return nil +} + func (c *container) BindingKeys() []key.Binding { if b, ok := c.content.(Bindings); ok { return b.BindingKeys() diff --git a/internal/tui/layout/layout.go b/internal/tui/layout/layout.go index 08aa3173ef89230a31962965d7607e4c043829c5..4d01ccc0834f944ebb12f8641ee6f1f2da0ec58d 100644 --- a/internal/tui/layout/layout.go +++ b/internal/tui/layout/layout.go @@ -22,6 +22,10 @@ type Bindings interface { BindingKeys() []key.Binding } +type Positionable interface { + SetPosition(x, y int) tea.Cmd +} + func KeyMapToSlice(t any) (bindings []key.Binding) { typ := reflect.TypeOf(t) if typ.Kind() != reflect.Struct { diff --git a/internal/tui/layout/split.go b/internal/tui/layout/split.go index c40a6a0e79771ec8ea669382a99aa387384abc58..bfd98b5059165f974283b4f3efb7b67713f1a41c 100644 --- a/internal/tui/layout/split.go +++ b/internal/tui/layout/split.go @@ -86,17 +86,17 @@ func (s *splitPaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return s, tea.Batch(cmds...) } -func (s *splitPaneLayout) View() string { +func (s *splitPaneLayout) View() tea.View { var topSection string if s.leftPanel != nil && s.rightPanel != nil { leftView := s.leftPanel.View() rightView := s.rightPanel.View() - topSection = lipgloss.JoinHorizontal(lipgloss.Top, leftView, rightView) + topSection = lipgloss.JoinHorizontal(lipgloss.Top, leftView.String(), rightView.String()) } else if s.leftPanel != nil { - topSection = s.leftPanel.View() + topSection = s.leftPanel.View().String() } else if s.rightPanel != nil { - topSection = s.rightPanel.View() + topSection = s.rightPanel.View().String() } else { topSection = "" } @@ -105,25 +105,33 @@ func (s *splitPaneLayout) View() string { if s.bottomPanel != nil && topSection != "" { bottomView := s.bottomPanel.View() - finalView = lipgloss.JoinVertical(lipgloss.Left, topSection, bottomView) + finalView = lipgloss.JoinVertical(lipgloss.Left, topSection, bottomView.String()) } else if s.bottomPanel != nil { - finalView = s.bottomPanel.View() + finalView = s.bottomPanel.View().String() } else { finalView = topSection } - if finalView != "" { - t := theme.CurrentTheme() + // TODO: think of a better way to handle multiple cursors + var cursor *tea.Cursor + if s.bottomPanel != nil { + cursor = s.bottomPanel.View().Cursor() + } else if s.rightPanel != nil { + cursor = s.rightPanel.View().Cursor() + } else if s.leftPanel != nil { + cursor = s.leftPanel.View().Cursor() + } - style := lipgloss.NewStyle(). - Width(s.width). - Height(s.height). - Background(t.Background()) + t := theme.CurrentTheme() - return style.Render(finalView) - } + style := lipgloss.NewStyle(). + Width(s.width). + Height(s.height). + Background(t.Background()) - return finalView + view := tea.NewView(style.Render(finalView)) + view.SetCursor(cursor) + return view } func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd { @@ -131,6 +139,7 @@ func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd { s.height = height var topHeight, bottomHeight int + var cmds []tea.Cmd if s.bottomPanel != nil { topHeight = int(float64(height) * s.verticalRatio) bottomHeight = height - topHeight @@ -151,20 +160,28 @@ func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd { rightWidth = width } - var cmds []tea.Cmd if s.leftPanel != nil { cmd := s.leftPanel.SetSize(leftWidth, topHeight) cmds = append(cmds, cmd) + if positionable, ok := s.leftPanel.(Positionable); ok { + cmds = append(cmds, positionable.SetPosition(0, 0)) + } } if s.rightPanel != nil { cmd := s.rightPanel.SetSize(rightWidth, topHeight) cmds = append(cmds, cmd) + if positionable, ok := s.rightPanel.(Positionable); ok { + cmds = append(cmds, positionable.SetPosition(leftWidth, 0)) + } } if s.bottomPanel != nil { cmd := s.bottomPanel.SetSize(width, bottomHeight) cmds = append(cmds, cmd) + if positionable, ok := s.bottomPanel.(Positionable); ok { + cmds = append(cmds, positionable.SetPosition(0, topHeight)) + } } return tea.Batch(cmds...) } diff --git a/internal/tui/page/chat.go b/internal/tui/page/chat.go index 4546b268f9c3b9649ddd6b00f025feb8bf3d092a..92166ca02e9f934db50a226d5b357736031ab4d3 100644 --- a/internal/tui/page/chat.go +++ b/internal/tui/page/chat.go @@ -185,7 +185,7 @@ func (p *chatPage) GetSize() (int, int) { return p.layout.GetSize() } -func (p *chatPage) View() string { +func (p *chatPage) View() tea.View { layoutView := p.layout.View() if p.showCompletionDialog { @@ -195,15 +195,20 @@ func (p *chatPage) View() string { p.completionDialog.SetWidth(editorWidth) overlay := p.completionDialog.View() - layoutView = layout.PlaceOverlay( + viewStr := layout.PlaceOverlay( 0, - layoutHeight-editorHeight-lipgloss.Height(overlay), - overlay, - layoutView, + layoutHeight-editorHeight-lipgloss.Height(overlay.String()), + overlay.String(), + layoutView.String(), false, ) + + view := tea.NewView(viewStr) + view.SetCursor(overlay.Cursor()) + return view } + logging.Info("Cursor in page", "c", layoutView.Cursor()) return layoutView } diff --git a/internal/tui/page/logs.go b/internal/tui/page/logs.go index 89c69b8654672987b4c49c1dec000fd83fb62031..63613d02d9c222e6f998b571779f43ae13e74668 100644 --- a/internal/tui/page/logs.go +++ b/internal/tui/page/logs.go @@ -42,12 +42,16 @@ func (p *logsPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return p, tea.Batch(cmds...) } -func (p *logsPage) View() string { +func (p *logsPage) View() tea.View { style := styles.BaseStyle().Width(p.width).Height(p.height) - return style.Render(lipgloss.JoinVertical(lipgloss.Top, - p.table.View(), - p.details.View(), - )) + return tea.NewView( + style.Render( + lipgloss.JoinVertical(lipgloss.Top, + p.table.View().String(), + p.details.View().String(), + ), + ), + ) } func (p *logsPage) BindingKeys() []key.Binding { diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 75f90d97b6c4c8117fcd28bbb3f2cfdf96934777..f3fffd9a383d4916698d8275885ed9a43e8b0665 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -1,676 +1,635 @@ package tui import ( - "context" - "fmt" - "strings" - "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" "github.com/opencode-ai/opencode/internal/app" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/llm/agent" "github.com/opencode-ai/opencode/internal/logging" - "github.com/opencode-ai/opencode/internal/permission" "github.com/opencode-ai/opencode/internal/pubsub" - "github.com/opencode-ai/opencode/internal/session" - "github.com/opencode-ai/opencode/internal/tui/components/chat" "github.com/opencode-ai/opencode/internal/tui/components/core" - "github.com/opencode-ai/opencode/internal/tui/components/dialog" + "github.com/opencode-ai/opencode/internal/tui/components/dialogs" + "github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands" + "github.com/opencode-ai/opencode/internal/tui/components/dialogs/quit" "github.com/opencode-ai/opencode/internal/tui/layout" "github.com/opencode-ai/opencode/internal/tui/page" "github.com/opencode-ai/opencode/internal/tui/theme" "github.com/opencode-ai/opencode/internal/tui/util" ) -type keyMap struct { - Logs key.Binding - Quit key.Binding - Help key.Binding - SwitchSession key.Binding - Commands key.Binding - Filepicker key.Binding - Models key.Binding - SwitchTheme key.Binding -} - -type startCompactSessionMsg struct{} - -const ( - quitKey = "q" -) - -var keys = keyMap{ - Logs: key.NewBinding( - key.WithKeys("ctrl+l"), - key.WithHelp("ctrl+l", "logs"), - ), - - Quit: key.NewBinding( - key.WithKeys("ctrl+c"), - key.WithHelp("ctrl+c", "quit"), - ), - Help: key.NewBinding( - key.WithKeys("ctrl+_"), - key.WithHelp("ctrl+?", "toggle help"), - ), - - SwitchSession: key.NewBinding( - key.WithKeys("ctrl+s"), - key.WithHelp("ctrl+s", "switch session"), - ), - - Commands: key.NewBinding( - key.WithKeys("ctrl+k"), - key.WithHelp("ctrl+k", "commands"), - ), - Filepicker: key.NewBinding( - key.WithKeys("ctrl+f"), - key.WithHelp("ctrl+f", "select files to upload"), - ), - Models: key.NewBinding( - key.WithKeys("ctrl+o"), - key.WithHelp("ctrl+o", "model selection"), - ), - - SwitchTheme: key.NewBinding( - key.WithKeys("ctrl+t"), - key.WithHelp("ctrl+t", "switch theme"), - ), -} - -var helpEsc = key.NewBinding( - key.WithKeys("?"), - key.WithHelp("?", "toggle help"), -) - -var returnKey = key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "close"), -) - -var logsKeyReturnKey = key.NewBinding( - key.WithKeys("esc", "backspace", quitKey), - key.WithHelp("esc/q", "go back"), -) +// type startCompactSessionMsg struct{} type appModel struct { - width, height int - currentPage page.PageID - previousPage page.PageID - pages map[page.PageID]util.Model - loadedPages map[page.PageID]bool - status core.StatusCmp - app *app.App - selectedSession session.Session - - showPermissions bool - permissions dialog.PermissionDialogCmp - - showHelp bool - help dialog.HelpCmp - - showQuit bool - quit dialog.QuitDialog - - showSessionDialog bool - sessionDialog dialog.SessionDialog - - showCommandDialog bool - commandDialog dialog.CommandDialog - commands []dialog.Command - - showModelDialog bool - modelDialog dialog.ModelDialog - - showInitDialog bool - initDialog dialog.InitDialogCmp - - showFilepicker bool - filepicker dialog.FilepickerCmp - - showThemeDialog bool - themeDialog dialog.ThemeDialog - - showMultiArgumentsDialog bool - multiArgumentsDialog dialog.MultiArgumentsDialogCmp - - isCompacting bool - compactingMessage string + width, height int + keyMap KeyMap + + currentPage page.PageID + previousPage page.PageID + pages map[page.PageID]util.Model + loadedPages map[page.PageID]bool + + status core.StatusCmp + + app *app.App + + // selectedSession session.Session + // + // showPermissions bool + // permissions dialog.PermissionDialogCmp + // + // showHelp bool + // help dialog.HelpCmp + // + // showSessionDialog bool + // sessionDialog dialog.SessionDialog + // + // showCommandDialog bool + // commandDialog dialog.CommandDialog + // commands []dialog.Command + // + // showModelDialog bool + // modelDialog dialog.ModelDialog + // + // showInitDialog bool + // initDialog dialog.InitDialogCmp + // + // showFilepicker bool + // filepicker dialog.FilepickerCmp + // + // showThemeDialog bool + // themeDialog dialog.ThemeDialog + // + // showMultiArgumentsDialog bool + // multiArgumentsDialog dialog.MultiArgumentsDialogCmp + // + // isCompacting bool + // compactingMessage string + + // NEW DIALOG + dialog dialogs.DialogCmp } func (a appModel) Init() tea.Cmd { var cmds []tea.Cmd cmd := a.pages[a.currentPage].Init() - t := theme.CurrentTheme() - cmds = append(cmds, tea.SetBackgroundColor(t.Background())) - a.loadedPages[a.currentPage] = true cmds = append(cmds, cmd) + a.loadedPages[a.currentPage] = true + cmd = a.status.Init() cmds = append(cmds, cmd) - cmd = a.quit.Init() - cmds = append(cmds, cmd) - cmd = a.help.Init() - cmds = append(cmds, cmd) - cmd = a.sessionDialog.Init() - cmds = append(cmds, cmd) - cmd = a.commandDialog.Init() - cmds = append(cmds, cmd) - cmd = a.modelDialog.Init() - cmds = append(cmds, cmd) - cmd = a.initDialog.Init() - cmds = append(cmds, cmd) - cmd = a.filepicker.Init() - cmds = append(cmds, cmd) - cmd = a.themeDialog.Init() - cmds = append(cmds, cmd) + // cmd = a.help.Init() + // cmds = append(cmds, cmd) + // cmd = a.sessionDialog.Init() + // cmds = append(cmds, cmd) + // cmd = a.commandDialog.Init() + // cmds = append(cmds, cmd) + // cmd = a.modelDialog.Init() + // cmds = append(cmds, cmd) + // cmd = a.initDialog.Init() + // cmds = append(cmds, cmd) + // cmd = a.filepicker.Init() + // cmds = append(cmds, cmd) + // cmd = a.themeDialog.Init() + // cmds = append(cmds, cmd) // Check if we should show the init dialog - cmds = append(cmds, func() tea.Msg { - shouldShow, err := config.ShouldShowInitDialog() - if err != nil { - return util.InfoMsg{ - Type: util.InfoTypeError, - Msg: "Failed to check init status: " + err.Error(), - } - } - return dialog.ShowInitDialogMsg{Show: shouldShow} - }) + // cmds = append(cmds, func() tea.Msg { + // shouldShow, err := config.ShouldShowInitDialog() + // if err != nil { + // return util.InfoMsg{ + // Type: util.InfoTypeError, + // Msg: "Failed to check init status: " + err.Error(), + // } + // } + // return dialog.ShowInitDialogMsg{Show: shouldShow} + // }) + t := theme.CurrentTheme() + cmds = append(cmds, tea.SetBackgroundColor(t.Background())) return tea.Batch(cmds...) } -func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd var cmd tea.Cmd + switch msg := msg.(type) { case tea.WindowSizeMsg: - msg.Height -= 1 // Make space for the status bar - a.width, a.height = msg.Width, msg.Height - - s, _ := a.status.Update(msg) - a.status = s.(core.StatusCmp) - updated, cmd := a.pages[a.currentPage].Update(msg) - a.pages[a.currentPage] = updated.(util.Model) - cmds = append(cmds, cmd) - - prm, permCmd := a.permissions.Update(msg) - a.permissions = prm.(dialog.PermissionDialogCmp) - cmds = append(cmds, permCmd) - - help, helpCmd := a.help.Update(msg) - a.help = help.(dialog.HelpCmp) - cmds = append(cmds, helpCmd) - - session, sessionCmd := a.sessionDialog.Update(msg) - a.sessionDialog = session.(dialog.SessionDialog) - cmds = append(cmds, sessionCmd) - - command, commandCmd := a.commandDialog.Update(msg) - a.commandDialog = command.(dialog.CommandDialog) - cmds = append(cmds, commandCmd) - - filepicker, filepickerCmd := a.filepicker.Update(msg) - a.filepicker = filepicker.(dialog.FilepickerCmp) - cmds = append(cmds, filepickerCmd) - - a.initDialog.SetSize(msg.Width, msg.Height) - - if a.showMultiArgumentsDialog { - a.multiArgumentsDialog.SetSize(msg.Width, msg.Height) - args, argsCmd := a.multiArgumentsDialog.Update(msg) - a.multiArgumentsDialog = args.(dialog.MultiArgumentsDialogCmp) - cmds = append(cmds, argsCmd, a.multiArgumentsDialog.Init()) - } + return a, a.handleWindowResize(msg) + // TODO: remove when refactor is done + // msg.Height -= 1 // Make space for the status bar + // a.width, a.height = msg.Width, msg.Height + // + // s, _ := a.status.Update(msg) + // a.status = s.(core.StatusCmp) + // updated, cmd := a.pages[a.currentPage].Update(msg) + // a.pages[a.currentPage] = updated.(util.Model) + // cmds = append(cmds, cmd) + // + // prm, permCmd := a.permissions.Update(msg) + // a.permissions = prm.(dialog.PermissionDialogCmp) + // cmds = append(cmds, permCmd) + // + // help, helpCmd := a.help.Update(msg) + // a.help = help.(dialog.HelpCmp) + // cmds = append(cmds, helpCmd) + // + // session, sessionCmd := a.sessionDialog.Update(msg) + // a.sessionDialog = session.(dialog.SessionDialog) + // cmds = append(cmds, sessionCmd) + // + // command, commandCmd := a.commandDialog.Update(msg) + // a.commandDialog = command.(dialog.CommandDialog) + // cmds = append(cmds, commandCmd) + // + // filepicker, filepickerCmd := a.filepicker.Update(msg) + // a.filepicker = filepicker.(dialog.FilepickerCmp) + // cmds = append(cmds, filepickerCmd) + // + // a.initDialog.SetSize(msg.Width, msg.Height) + // + // if a.showMultiArgumentsDialog { + // a.multiArgumentsDialog.SetSize(msg.Width, msg.Height) + // args, argsCmd := a.multiArgumentsDialog.Update(msg) + // a.multiArgumentsDialog = args.(dialog.MultiArgumentsDialogCmp) + // cmds = append(cmds, argsCmd, a.multiArgumentsDialog.Init()) + // } + // + // dialog, cmd := a.dialog.Update(msg) + // a.dialog = dialog.(dialogs.DialogCmp) + // cmds = append(cmds, cmd) + // + // return a, tea.Batch(cmds...) + + // Dialog messages + case dialogs.OpenDialogMsg, dialogs.CloseDialogMsg: + u, dialogCmd := a.dialog.Update(msg) + a.dialog = u.(dialogs.DialogCmp) + return a, dialogCmd + + // Page change messages + case page.PageChangeMsg: + return a, a.moveToPage(msg.ID) - return a, tea.Batch(cmds...) - // Status - case util.InfoMsg: + // Status Messages + case util.InfoMsg, util.ClearStatusMsg: s, cmd := a.status.Update(msg) a.status = s.(core.StatusCmp) cmds = append(cmds, cmd) return a, tea.Batch(cmds...) + // Logs case pubsub.Event[logging.LogMessage]: - if msg.Payload.Persist { - switch msg.Payload.Level { - case "error": - s, cmd := a.status.Update(util.InfoMsg{ - Type: util.InfoTypeError, - Msg: msg.Payload.Message, - TTL: msg.Payload.PersistTime, - }) - a.status = s.(core.StatusCmp) - cmds = append(cmds, cmd) - case "info": - s, cmd := a.status.Update(util.InfoMsg{ - Type: util.InfoTypeInfo, - Msg: msg.Payload.Message, - TTL: msg.Payload.PersistTime, - }) - a.status = s.(core.StatusCmp) - cmds = append(cmds, cmd) - - case "warn": - s, cmd := a.status.Update(util.InfoMsg{ - Type: util.InfoTypeWarn, - Msg: msg.Payload.Message, - TTL: msg.Payload.PersistTime, - }) - - a.status = s.(core.StatusCmp) - cmds = append(cmds, cmd) - default: - s, cmd := a.status.Update(util.InfoMsg{ - Type: util.InfoTypeInfo, - Msg: msg.Payload.Message, - TTL: msg.Payload.PersistTime, - }) - a.status = s.(core.StatusCmp) - cmds = append(cmds, cmd) - } - } - case util.ClearStatusMsg: - s, _ := a.status.Update(msg) + // Send to the status component + s, cmd := a.status.Update(msg) a.status = s.(core.StatusCmp) + cmds = append(cmds, cmd) - // Permission - case pubsub.Event[permission.PermissionRequest]: - a.showPermissions = true - return a, a.permissions.SetPermissions(msg.Payload) - case dialog.PermissionResponseMsg: - var cmd tea.Cmd - switch msg.Action { - case dialog.PermissionAllow: - a.app.Permissions.Grant(msg.Permission) - case dialog.PermissionAllowForSession: - a.app.Permissions.GrantPersistant(msg.Permission) - case dialog.PermissionDeny: - a.app.Permissions.Deny(msg.Permission) + // If the current page is logs, update the logs view + if a.currentPage == page.LogsPage { + updated, cmd := a.pages[a.currentPage].Update(msg) + a.pages[a.currentPage] = updated.(util.Model) + cmds = append(cmds, cmd) } - a.showPermissions = false - return a, cmd - - case page.PageChangeMsg: - return a, a.moveToPage(msg.ID) - - case dialog.CloseQuitMsg: - a.showQuit = false - return a, nil - - case dialog.CloseSessionDialogMsg: - a.showSessionDialog = false - return a, nil - - case dialog.CloseCommandDialogMsg: - a.showCommandDialog = false - return a, nil - - case startCompactSessionMsg: - // Start compacting the current session - a.isCompacting = true - a.compactingMessage = "Starting summarization..." + return a, tea.Batch(cmds...) - if a.selectedSession.ID == "" { - a.isCompacting = false - return a, util.ReportWarn("No active session to summarize") - } + // // Permission + // case pubsub.Event[permission.PermissionRequest]: + // a.showPermissions = true + // return a, a.permissions.SetPermissions(msg.Payload) + // case dialog.PermissionResponseMsg: + // var cmd tea.Cmd + // switch msg.Action { + // case dialog.PermissionAllow: + // a.app.Permissions.Grant(msg.Permission) + // case dialog.PermissionAllowForSession: + // a.app.Permissions.GrantPersistant(msg.Permission) + // case dialog.PermissionDeny: + // a.app.Permissions.Deny(msg.Permission) + // } + // a.showPermissions = false + // return a, cmd + // + // // Theme changed + // case dialog.ThemeChangedMsg: + // updated, cmd := a.pages[a.currentPage].Update(msg) + // a.pages[a.currentPage] = updated.(util.Model) + // a.showThemeDialog = false + // return a, tea.Batch(cmd, util.ReportInfo("Theme changed to: "+msg.ThemeName)) + // + // case dialog.CloseSessionDialogMsg: + // a.showSessionDialog = false + // return a, nil + // + // case dialog.CloseCommandDialogMsg: + // a.showCommandDialog = false + // return a, nil + // + // case startCompactSessionMsg: + // // Start compacting the current session + // a.isCompacting = true + // a.compactingMessage = "Starting summarization..." + // + // if a.selectedSession.ID == "" { + // a.isCompacting = false + // return a, util.ReportWarn("No active session to summarize") + // } + // + // // Start the summarization process + // return a, func() tea.Msg { + // ctx := context.Background() + // a.app.CoderAgent.Summarize(ctx, a.selectedSession.ID) + // return nil + // } + // + // case pubsub.Event[agent.AgentEvent]: + // payload := msg.Payload + // if payload.Error != nil { + // a.isCompacting = false + // return a, util.ReportError(payload.Error) + // } + // + // a.compactingMessage = payload.Progress + // + // if payload.Done && payload.Type == agent.AgentEventTypeSummarize { + // a.isCompacting = false + // return a, util.ReportInfo("Session summarization complete") + // } else if payload.Done && payload.Type == agent.AgentEventTypeResponse && a.selectedSession.ID != "" { + // model := a.app.CoderAgent.Model() + // contextWindow := model.ContextWindow + // tokens := a.selectedSession.CompletionTokens + a.selectedSession.PromptTokens + // if (tokens >= int64(float64(contextWindow)*0.95)) && config.Get().AutoCompact { + // return a, util.CmdHandler(startCompactSessionMsg{}) + // } + // } + // // Continue listening for events + // return a, nil + // + // case dialog.CloseThemeDialogMsg: + // a.showThemeDialog = false + // return a, nil + // + // case dialog.CloseModelDialogMsg: + // a.showModelDialog = false + // return a, nil + // + // case dialog.ModelSelectedMsg: + // a.showModelDialog = false + // + // model, err := a.app.CoderAgent.Update(config.AgentCoder, msg.Model.ID) + // if err != nil { + // return a, util.ReportError(err) + // } + // + // return a, util.ReportInfo(fmt.Sprintf("Model changed to %s", model.Name)) + // + // case dialog.ShowInitDialogMsg: + // a.showInitDialog = msg.Show + // return a, nil + // + // case dialog.CloseInitDialogMsg: + // a.showInitDialog = false + // if msg.Initialize { + // // Run the initialization command + // for _, cmd := range a.commands { + // if cmd.ID == "init" { + // // Mark the project as initialized + // if err := config.MarkProjectInitialized(); err != nil { + // return a, util.ReportError(err) + // } + // return a, cmd.Handler(cmd) + // } + // } + // } else { + // // Mark the project as initialized without running the command + // if err := config.MarkProjectInitialized(); err != nil { + // return a, util.ReportError(err) + // } + // } + // return a, nil + // + // case chat.SessionSelectedMsg: + // a.selectedSession = msg + // a.sessionDialog.SetSelectedSession(msg.ID) + // + // case pubsub.Event[session.Session]: + // if msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == a.selectedSession.ID { + // a.selectedSession = msg.Payload + // } + // case dialog.SessionSelectedMsg: + // a.showSessionDialog = false + // if a.currentPage == page.ChatPage { + // return a, util.CmdHandler(chat.SessionSelectedMsg(msg.Session)) + // } + // return a, nil + // + // case dialog.CommandSelectedMsg: + // a.showCommandDialog = false + // // Execute the command handler if available + // if msg.Command.Handler != nil { + // return a, msg.Command.Handler(msg.Command) + // } + // return a, util.ReportInfo("Command selected: " + msg.Command.Title) + // + // case dialog.ShowMultiArgumentsDialogMsg: + // // Show multi-arguments dialog + // a.multiArgumentsDialog = dialog.NewMultiArgumentsDialogCmp(msg.CommandID, msg.Content, msg.ArgNames) + // a.showMultiArgumentsDialog = true + // return a, a.multiArgumentsDialog.Init() + // + // case dialog.CloseMultiArgumentsDialogMsg: + // // Close multi-arguments dialog + // a.showMultiArgumentsDialog = false + // + // // If submitted, replace all named arguments and run the command + // if msg.Submit { + // content := msg.Content + // + // // Replace each named argument with its value + // for name, value := range msg.Args { + // placeholder := "$" + name + // content = strings.ReplaceAll(content, placeholder, value) + // } + // + // // Execute the command with arguments + // return a, util.CmdHandler(dialog.CommandRunCustomMsg{ + // Content: content, + // Args: msg.Args, + // }) + // } + // return a, nil + // + case tea.KeyPressMsg: + return a, a.handleKeyPressMsg(msg) + // if a.dialog.HasDialogs() { + // u, dialogCmd := a.dialog.Update(msg) + // a.dialog = u.(dialogs.DialogCmp) + // return a, dialogCmd + // } + // // If multi-arguments dialog is open, let it handle the key press first + // if a.showMultiArgumentsDialog { + // args, cmd := a.multiArgumentsDialog.Update(msg) + // a.multiArgumentsDialog = args.(dialog.MultiArgumentsDialogCmp) + // return a, cmd + // } + // + // switch { + // case key.Matches(msg, keys.Quit): + // // TODO: fix this after testing + // // a.showQuit = !a.showQuit + // // if a.showHelp { + // // a.showHelp = false + // // } + // // if a.showSessionDialog { + // // a.showSessionDialog = false + // // } + // // if a.showCommandDialog { + // // a.showCommandDialog = false + // // } + // // if a.showFilepicker { + // // a.showFilepicker = false + // // a.filepicker.ToggleFilepicker(a.showFilepicker) + // // } + // // if a.showModelDialog { + // // a.showModelDialog = false + // // } + // // if a.showMultiArgumentsDialog { + // // a.showMultiArgumentsDialog = false + // // } + // return a, util.CmdHandler(dialogs.OpenDialogMsg{ + // Model: quit.NewQuitDialog(), + // }) + // case key.Matches(msg, keys.SwitchSession): + // if a.currentPage == page.ChatPage && !a.showPermissions && !a.showCommandDialog { + // // Load sessions and show the dialog + // sessions, err := a.app.Sessions.List(context.Background()) + // if err != nil { + // return a, util.ReportError(err) + // } + // if len(sessions) == 0 { + // return a, util.ReportWarn("No sessions available") + // } + // a.sessionDialog.SetSessions(sessions) + // a.showSessionDialog = true + // return a, nil + // } + // return a, nil + // case key.Matches(msg, keys.Commands): + // if a.currentPage == page.ChatPage && !a.showPermissions && !a.showSessionDialog && !a.showThemeDialog && !a.showFilepicker { + // // Show commands dialog + // if len(a.commands) == 0 { + // return a, util.ReportWarn("No commands available") + // } + // a.commandDialog.SetCommands(a.commands) + // a.showCommandDialog = true + // return a, nil + // } + // return a, util.CmdHandler(dialogs.OpenDialogMsg{ + // Model: commands.NewCommandDialog(), + // }) + // case key.Matches(msg, keys.Models): + // if a.showModelDialog { + // a.showModelDialog = false + // return a, nil + // } + // if a.currentPage == page.ChatPage && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog { + // a.showModelDialog = true + // return a, nil + // } + // return a, nil + // case key.Matches(msg, keys.SwitchTheme): + // if !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog { + // // Show theme switcher dialog + // a.showThemeDialog = true + // // Theme list is dynamically loaded by the dialog component + // return a, a.themeDialog.Init() + // } + // return a, nil + // case key.Matches(msg, returnKey) || key.Matches(msg): + // if msg.String() == quitKey { + // if a.currentPage == page.LogsPage { + // return a, a.moveToPage(page.ChatPage) + // } + // } else if !a.filepicker.IsCWDFocused() { + // if a.showHelp { + // a.showHelp = !a.showHelp + // return a, nil + // } + // if a.showInitDialog { + // a.showInitDialog = false + // // Mark the project as initialized without running the command + // if err := config.MarkProjectInitialized(); err != nil { + // return a, util.ReportError(err) + // } + // return a, nil + // } + // if a.showFilepicker { + // a.showFilepicker = false + // a.filepicker.ToggleFilepicker(a.showFilepicker) + // return a, nil + // } + // if a.currentPage == page.LogsPage { + // return a, a.moveToPage(page.ChatPage) + // } + // } + // case key.Matches(msg, keys.Logs): + // return a, a.moveToPage(page.LogsPage) + // case key.Matches(msg, keys.Help): + // a.showHelp = !a.showHelp + // return a, nil + // case key.Matches(msg, helpEsc): + // if a.app.CoderAgent.IsBusy() { + // a.showHelp = !a.showHelp + // return a, nil + // } + // case key.Matches(msg, keys.Filepicker): + // a.showFilepicker = !a.showFilepicker + // a.filepicker.ToggleFilepicker(a.showFilepicker) + // return a, nil + // } + // default: + // u, dialogCmd := a.dialog.Update(msg) + // a.dialog = u.(dialogs.DialogCmp) + // cmds = append(cmds, dialogCmd) + // f, filepickerCmd := a.filepicker.Update(msg) + // a.filepicker = f.(dialog.FilepickerCmp) + // cmds = append(cmds, filepickerCmd) + // } + + // if a.showFilepicker { + // f, filepickerCmd := a.filepicker.Update(msg) + // a.filepicker = f.(dialog.FilepickerCmp) + // cmds = append(cmds, filepickerCmd) + // // Only block key messages send all other messages down + // if _, ok := msg.(tea.KeyPressMsg); ok { + // return a, tea.Batch(cmds...) + // } + // } + // + // if a.showPermissions { + // d, permissionsCmd := a.permissions.Update(msg) + // a.permissions = d.(dialog.PermissionDialogCmp) + // cmds = append(cmds, permissionsCmd) + // // Only block key messages send all other messages down + // if _, ok := msg.(tea.KeyPressMsg); ok { + // return a, tea.Batch(cmds...) + // } + // } + // + // if a.showSessionDialog { + // d, sessionCmd := a.sessionDialog.Update(msg) + // a.sessionDialog = d.(dialog.SessionDialog) + // cmds = append(cmds, sessionCmd) + // // Only block key messages send all other messages down + // if _, ok := msg.(tea.KeyPressMsg); ok { + // return a, tea.Batch(cmds...) + // } + // } + // + // if a.showCommandDialog { + // d, commandCmd := a.commandDialog.Update(msg) + // a.commandDialog = d.(dialog.CommandDialog) + // cmds = append(cmds, commandCmd) + // // Only block key messages send all other messages down + // if _, ok := msg.(tea.KeyPressMsg); ok { + // return a, tea.Batch(cmds...) + // } + // } + // + // if a.showModelDialog { + // d, modelCmd := a.modelDialog.Update(msg) + // a.modelDialog = d.(dialog.ModelDialog) + // cmds = append(cmds, modelCmd) + // // Only block key messages send all other messages down + // if _, ok := msg.(tea.KeyPressMsg); ok { + // return a, tea.Batch(cmds...) + // } + // } + // + // if a.showInitDialog { + // d, initCmd := a.initDialog.Update(msg) + // a.initDialog = d.(dialog.InitDialogCmp) + // cmds = append(cmds, initCmd) + // // Only block key messages send all other messages down + // if _, ok := msg.(tea.KeyPressMsg); ok { + // return a, tea.Batch(cmds...) + // } + // } + // + // if a.showThemeDialog { + // d, themeCmd := a.themeDialog.Update(msg) + // a.themeDialog = d.(dialog.ThemeDialog) + // cmds = append(cmds, themeCmd) + // // Only block key messages send all other messages down + // if _, ok := msg.(tea.KeyPressMsg); ok { + // return a, tea.Batch(cmds...) + // } + } + // + s, _ := a.status.Update(msg) + a.status = s.(core.StatusCmp) + updated, cmd := a.pages[a.currentPage].Update(msg) + a.pages[a.currentPage] = updated.(util.Model) + cmds = append(cmds, cmd) + return a, tea.Batch(cmds...) +} - // Start the summarization process - return a, func() tea.Msg { - ctx := context.Background() - a.app.CoderAgent.Summarize(ctx, a.selectedSession.ID) - return nil - } +func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd { + var cmds []tea.Cmd + msg.Height -= 1 // Make space for the status bar + a.width, a.height = msg.Width, msg.Height - case pubsub.Event[agent.AgentEvent]: - payload := msg.Payload - if payload.Error != nil { - a.isCompacting = false - return a, util.ReportError(payload.Error) - } + // Update status bar + s, cmd := a.status.Update(msg) + a.status = s.(core.StatusCmp) + cmds = append(cmds, cmd) - a.compactingMessage = payload.Progress - - if payload.Done && payload.Type == agent.AgentEventTypeSummarize { - a.isCompacting = false - return a, util.ReportInfo("Session summarization complete") - } else if payload.Done && payload.Type == agent.AgentEventTypeResponse && a.selectedSession.ID != "" { - model := a.app.CoderAgent.Model() - contextWindow := model.ContextWindow - tokens := a.selectedSession.CompletionTokens + a.selectedSession.PromptTokens - if (tokens >= int64(float64(contextWindow)*0.95)) && config.Get().AutoCompact { - return a, util.CmdHandler(startCompactSessionMsg{}) - } - } - // Continue listening for events - return a, nil - - case dialog.CloseThemeDialogMsg: - a.showThemeDialog = false - return a, nil - - case dialog.ThemeChangedMsg: - updated, cmd := a.pages[a.currentPage].Update(msg) - a.pages[a.currentPage] = updated.(util.Model) - a.showThemeDialog = false - t := theme.CurrentTheme() - return a, tea.Batch(cmd, util.ReportInfo("Theme changed to: "+msg.ThemeName), tea.SetBackgroundColor(t.Background())) - - case dialog.CloseModelDialogMsg: - a.showModelDialog = false - return a, nil - - case dialog.ModelSelectedMsg: - a.showModelDialog = false - - model, err := a.app.CoderAgent.Update(config.AgentCoder, msg.Model.ID) - if err != nil { - return a, util.ReportError(err) - } + // Update the current page + updated, cmd := a.pages[a.currentPage].Update(msg) + a.pages[a.currentPage] = updated.(util.Model) + cmds = append(cmds, cmd) - return a, util.ReportInfo(fmt.Sprintf("Model changed to %s", model.Name)) - - case dialog.ShowInitDialogMsg: - a.showInitDialog = msg.Show - return a, nil - - case dialog.CloseInitDialogMsg: - a.showInitDialog = false - if msg.Initialize { - // Run the initialization command - for _, cmd := range a.commands { - if cmd.ID == "init" { - // Mark the project as initialized - if err := config.MarkProjectInitialized(); err != nil { - return a, util.ReportError(err) - } - return a, cmd.Handler(cmd) - } - } - } else { - // Mark the project as initialized without running the command - if err := config.MarkProjectInitialized(); err != nil { - return a, util.ReportError(err) - } - } - return a, nil + // Update the dialogs + dialog, cmd := a.dialog.Update(msg) + a.dialog = dialog.(dialogs.DialogCmp) + cmds = append(cmds, cmd) - case chat.SessionSelectedMsg: - a.selectedSession = msg - a.sessionDialog.SetSelectedSession(msg.ID) + return tea.Batch(cmds...) +} - case pubsub.Event[session.Session]: - if msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == a.selectedSession.ID { - a.selectedSession = msg.Payload - } - case dialog.SessionSelectedMsg: - a.showSessionDialog = false - if a.currentPage == page.ChatPage { - return a, util.CmdHandler(chat.SessionSelectedMsg(msg.Session)) - } - return a, nil +func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { + switch { + // dialogs + case key.Matches(msg, a.keyMap.Quit): + return util.CmdHandler(dialogs.OpenDialogMsg{ + Model: quit.NewQuitDialog(), + }) - case dialog.CommandSelectedMsg: - a.showCommandDialog = false - // Execute the command handler if available - if msg.Command.Handler != nil { - return a, msg.Command.Handler(msg.Command) - } - return a, util.ReportInfo("Command selected: " + msg.Command.Title) - - case dialog.ShowMultiArgumentsDialogMsg: - // Show multi-arguments dialog - a.multiArgumentsDialog = dialog.NewMultiArgumentsDialogCmp(msg.CommandID, msg.Content, msg.ArgNames) - a.showMultiArgumentsDialog = true - return a, a.multiArgumentsDialog.Init() - - case dialog.CloseMultiArgumentsDialogMsg: - // Close multi-arguments dialog - a.showMultiArgumentsDialog = false - - // If submitted, replace all named arguments and run the command - if msg.Submit { - content := msg.Content - - // Replace each named argument with its value - for name, value := range msg.Args { - placeholder := "$" + name - content = strings.ReplaceAll(content, placeholder, value) - } - - // Execute the command with arguments - return a, util.CmdHandler(dialog.CommandRunCustomMsg{ - Content: content, - Args: msg.Args, - }) - } - return a, nil + case key.Matches(msg, a.keyMap.Commands): + return util.CmdHandler(dialogs.OpenDialogMsg{ + Model: commands.NewCommandDialog(), + }) - case tea.KeyPressMsg: - // If multi-arguments dialog is open, let it handle the key press first - if a.showMultiArgumentsDialog { - args, cmd := a.multiArgumentsDialog.Update(msg) - a.multiArgumentsDialog = args.(dialog.MultiArgumentsDialogCmp) - return a, cmd - } + // Page navigation + case key.Matches(msg, a.keyMap.Logs): + return a.moveToPage(page.LogsPage) - switch { - case key.Matches(msg, keys.Quit): - a.showQuit = !a.showQuit - if a.showHelp { - a.showHelp = false - } - if a.showSessionDialog { - a.showSessionDialog = false - } - if a.showCommandDialog { - a.showCommandDialog = false - } - if a.showFilepicker { - a.showFilepicker = false - a.filepicker.ToggleFilepicker(a.showFilepicker) - } - if a.showModelDialog { - a.showModelDialog = false - } - if a.showMultiArgumentsDialog { - a.showMultiArgumentsDialog = false - } - return a, nil - case key.Matches(msg, keys.SwitchSession): - if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showCommandDialog { - // Load sessions and show the dialog - sessions, err := a.app.Sessions.List(context.Background()) - if err != nil { - return a, util.ReportError(err) - } - if len(sessions) == 0 { - return a, util.ReportWarn("No sessions available") - } - a.sessionDialog.SetSessions(sessions) - a.showSessionDialog = true - return a, nil - } - return a, nil - case key.Matches(msg, keys.Commands): - if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showThemeDialog && !a.showFilepicker { - // Show commands dialog - if len(a.commands) == 0 { - return a, util.ReportWarn("No commands available") - } - a.commandDialog.SetCommands(a.commands) - a.showCommandDialog = true - return a, nil - } - return a, nil - case key.Matches(msg, keys.Models): - if a.showModelDialog { - a.showModelDialog = false - return a, nil - } - if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog { - a.showModelDialog = true - return a, nil - } - return a, nil - case key.Matches(msg, keys.SwitchTheme): - if !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog { - // Show theme switcher dialog - a.showThemeDialog = true - // Theme list is dynamically loaded by the dialog component - return a, a.themeDialog.Init() - } - return a, nil - case key.Matches(msg, returnKey) || key.Matches(msg): - if msg.String() == quitKey { - if a.currentPage == page.LogsPage { - return a, a.moveToPage(page.ChatPage) - } - } else if !a.filepicker.IsCWDFocused() { - if a.showQuit { - a.showQuit = !a.showQuit - return a, nil - } - if a.showHelp { - a.showHelp = !a.showHelp - return a, nil - } - if a.showInitDialog { - a.showInitDialog = false - // Mark the project as initialized without running the command - if err := config.MarkProjectInitialized(); err != nil { - return a, util.ReportError(err) - } - return a, nil - } - if a.showFilepicker { - a.showFilepicker = false - a.filepicker.ToggleFilepicker(a.showFilepicker) - return a, nil - } - if a.currentPage == page.LogsPage { - return a, a.moveToPage(page.ChatPage) - } - } - case key.Matches(msg, keys.Logs): - return a, a.moveToPage(page.LogsPage) - case key.Matches(msg, keys.Help): - if a.showQuit { - return a, nil - } - a.showHelp = !a.showHelp - return a, nil - case key.Matches(msg, helpEsc): - if a.app.CoderAgent.IsBusy() { - if a.showQuit { - return a, nil - } - a.showHelp = !a.showHelp - return a, nil - } - case key.Matches(msg, keys.Filepicker): - a.showFilepicker = !a.showFilepicker - a.filepicker.ToggleFilepicker(a.showFilepicker) - return a, nil - } default: - f, filepickerCmd := a.filepicker.Update(msg) - a.filepicker = f.(dialog.FilepickerCmp) - cmds = append(cmds, filepickerCmd) - } - - if a.showFilepicker { - f, filepickerCmd := a.filepicker.Update(msg) - a.filepicker = f.(dialog.FilepickerCmp) - cmds = append(cmds, filepickerCmd) - // Only block key messages send all other messages down - if _, ok := msg.(tea.KeyPressMsg); ok { - return a, tea.Batch(cmds...) - } - } - - if a.showQuit { - q, quitCmd := a.quit.Update(msg) - a.quit = q.(dialog.QuitDialog) - cmds = append(cmds, quitCmd) - // Only block key messages send all other messages down - if _, ok := msg.(tea.KeyPressMsg); ok { - return a, tea.Batch(cmds...) - } - } - if a.showPermissions { - d, permissionsCmd := a.permissions.Update(msg) - a.permissions = d.(dialog.PermissionDialogCmp) - cmds = append(cmds, permissionsCmd) - // Only block key messages send all other messages down - if _, ok := msg.(tea.KeyPressMsg); ok { - return a, tea.Batch(cmds...) - } - } - - if a.showSessionDialog { - d, sessionCmd := a.sessionDialog.Update(msg) - a.sessionDialog = d.(dialog.SessionDialog) - cmds = append(cmds, sessionCmd) - // Only block key messages send all other messages down - if _, ok := msg.(tea.KeyPressMsg); ok { - return a, tea.Batch(cmds...) - } - } - - if a.showCommandDialog { - d, commandCmd := a.commandDialog.Update(msg) - a.commandDialog = d.(dialog.CommandDialog) - cmds = append(cmds, commandCmd) - // Only block key messages send all other messages down - if _, ok := msg.(tea.KeyPressMsg); ok { - return a, tea.Batch(cmds...) - } - } - - if a.showModelDialog { - d, modelCmd := a.modelDialog.Update(msg) - a.modelDialog = d.(dialog.ModelDialog) - cmds = append(cmds, modelCmd) - // Only block key messages send all other messages down - if _, ok := msg.(tea.KeyPressMsg); ok { - return a, tea.Batch(cmds...) - } - } - - if a.showInitDialog { - d, initCmd := a.initDialog.Update(msg) - a.initDialog = d.(dialog.InitDialogCmp) - cmds = append(cmds, initCmd) - // Only block key messages send all other messages down - if _, ok := msg.(tea.KeyPressMsg); ok { - return a, tea.Batch(cmds...) - } - } - - if a.showThemeDialog { - d, themeCmd := a.themeDialog.Update(msg) - a.themeDialog = d.(dialog.ThemeDialog) - cmds = append(cmds, themeCmd) - // Only block key messages send all other messages down - if _, ok := msg.(tea.KeyPressMsg); ok { - return a, tea.Batch(cmds...) + if a.dialog.HasDialogs() { + u, dialogCmd := a.dialog.Update(msg) + a.dialog = u.(dialogs.DialogCmp) + return dialogCmd + } else { + updated, cmd := a.pages[a.currentPage].Update(msg) + a.pages[a.currentPage] = updated.(util.Model) + return cmd } } - - s, _ := a.status.Update(msg) - a.status = s.(core.StatusCmp) - updated, cmd := a.pages[a.currentPage].Update(msg) - a.pages[a.currentPage] = updated.(util.Model) - cmds = append(cmds, cmd) - return a, tea.Batch(cmds...) } // RegisterCommand adds a command to the command dialog -func (a *appModel) RegisterCommand(cmd dialog.Command) { - a.commands = append(a.commands, cmd) -} +// func (a *appModel) RegisterCommand(cmd dialog.Command) { +// a.commands = append(a.commands, cmd) +// } func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd { if a.app.CoderAgent.IsBusy() { @@ -694,265 +653,276 @@ func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd { return tea.Batch(cmds...) } -func (a appModel) View() string { +func (a *appModel) View() tea.View { + pageView := a.pages[a.currentPage].View() components := []string{ - a.pages[a.currentPage].View(), + pageView.String(), } - components = append(components, a.status.View()) + components = append(components, a.status.View().String()) appView := lipgloss.JoinVertical(lipgloss.Top, components...) - if a.showPermissions { - overlay := a.permissions.View() - row := lipgloss.Height(appView) / 2 - row -= lipgloss.Height(overlay) / 2 - col := lipgloss.Width(appView) / 2 - col -= lipgloss.Width(overlay) / 2 - appView = layout.PlaceOverlay( - col, - row, - overlay, - appView, - true, - ) - } - - if a.showFilepicker { - overlay := a.filepicker.View() - row := lipgloss.Height(appView) / 2 - row -= lipgloss.Height(overlay) / 2 - col := lipgloss.Width(appView) / 2 - col -= lipgloss.Width(overlay) / 2 - appView = layout.PlaceOverlay( - col, - row, - overlay, - appView, - true, - ) - } - - // Show compacting status overlay - if a.isCompacting { - t := theme.CurrentTheme() - style := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.BorderFocused()). - BorderBackground(t.Background()). - Padding(1, 2). - Background(t.Background()). - Foreground(t.Text()) - - overlay := style.Render("Summarizing\n" + a.compactingMessage) - row := lipgloss.Height(appView) / 2 - row -= lipgloss.Height(overlay) / 2 - col := lipgloss.Width(appView) / 2 - col -= lipgloss.Width(overlay) / 2 - appView = layout.PlaceOverlay( - col, - row, - overlay, - appView, - true, - ) - } - - if a.showHelp { - bindings := layout.KeyMapToSlice(keys) - if p, ok := a.pages[a.currentPage].(layout.Bindings); ok { - bindings = append(bindings, p.BindingKeys()...) - } - if a.showPermissions { - bindings = append(bindings, a.permissions.BindingKeys()...) - } - if a.currentPage == page.LogsPage { - bindings = append(bindings, logsKeyReturnKey) - } - if !a.app.CoderAgent.IsBusy() { - bindings = append(bindings, helpEsc) - } - a.help.SetBindings(bindings) - - overlay := a.help.View() - row := lipgloss.Height(appView) / 2 - row -= lipgloss.Height(overlay) / 2 - col := lipgloss.Width(appView) / 2 - col -= lipgloss.Width(overlay) / 2 - appView = layout.PlaceOverlay( - col, - row, - overlay, - appView, - true, - ) - } - - if a.showQuit { - overlay := a.quit.View() - row := lipgloss.Height(appView) / 2 - row -= lipgloss.Height(overlay) / 2 - col := lipgloss.Width(appView) / 2 - col -= lipgloss.Width(overlay) / 2 - appView = layout.PlaceOverlay( - col, - row, - overlay, - appView, - true, - ) - } - - if a.showSessionDialog { - overlay := a.sessionDialog.View() - row := lipgloss.Height(appView) / 2 - row -= lipgloss.Height(overlay) / 2 - col := lipgloss.Width(appView) / 2 - col -= lipgloss.Width(overlay) / 2 - appView = layout.PlaceOverlay( - col, - row, - overlay, - appView, - true, - ) - } - - if a.showModelDialog { - overlay := a.modelDialog.View() - row := lipgloss.Height(appView) / 2 - row -= lipgloss.Height(overlay) / 2 - col := lipgloss.Width(appView) / 2 - col -= lipgloss.Width(overlay) / 2 - appView = layout.PlaceOverlay( - col, - row, - overlay, - appView, - true, - ) - } - - if a.showCommandDialog { - overlay := a.commandDialog.View() - row := lipgloss.Height(appView) / 2 - row -= lipgloss.Height(overlay) / 2 - col := lipgloss.Width(appView) / 2 - col -= lipgloss.Width(overlay) / 2 - appView = layout.PlaceOverlay( - col, - row, - overlay, - appView, - true, - ) - } - - if a.showInitDialog { - overlay := a.initDialog.View() - appView = layout.PlaceOverlay( - a.width/2-lipgloss.Width(overlay)/2, - a.height/2-lipgloss.Height(overlay)/2, - overlay, - appView, - true, - ) - } - - if a.showThemeDialog { - overlay := a.themeDialog.View() - row := lipgloss.Height(appView) / 2 - row -= lipgloss.Height(overlay) / 2 - col := lipgloss.Width(appView) / 2 - col -= lipgloss.Width(overlay) / 2 - appView = layout.PlaceOverlay( - col, - row, - overlay, - appView, - true, + // if a.showPermissions { + // overlay := a.permissions.View().String() + // row := lipgloss.Height(appView) / 2 + // row -= lipgloss.Height(overlay) / 2 + // col := lipgloss.Width(appView) / 2 + // col -= lipgloss.Width(overlay) / 2 + // appView = layout.PlaceOverlay( + // col, + // row, + // overlay, + // appView, + // true, + // ) + // } + // + // if a.showFilepicker { + // overlay := a.filepicker.View().String() + // row := lipgloss.Height(appView) / 2 + // row -= lipgloss.Height(overlay) / 2 + // col := lipgloss.Width(appView) / 2 + // col -= lipgloss.Width(overlay) / 2 + // appView = layout.PlaceOverlay( + // col, + // row, + // overlay, + // appView, + // true, + // ) + // } + // + // // Show compacting status overlay + // if a.isCompacting { + // t := theme.CurrentTheme() + // style := lipgloss.NewStyle(). + // Border(lipgloss.RoundedBorder()). + // BorderForeground(t.BorderFocused()). + // BorderBackground(t.Background()). + // Padding(1, 2). + // Background(t.Background()). + // Foreground(t.Text()) + // + // overlay := style.Render("Summarizing\n" + a.compactingMessage) + // row := lipgloss.Height(appView) / 2 + // row -= lipgloss.Height(overlay) / 2 + // col := lipgloss.Width(appView) / 2 + // col -= lipgloss.Width(overlay) / 2 + // appView = layout.PlaceOverlay( + // col, + // row, + // overlay, + // appView, + // true, + // ) + // } + // + // if a.showHelp { + // bindings := layout.KeyMapToSlice(a.keymap) + // if p, ok := a.pages[a.currentPage].(layout.Bindings); ok { + // bindings = append(bindings, p.BindingKeys()...) + // } + // if a.showPermissions { + // bindings = append(bindings, a.permissions.BindingKeys()...) + // } + // if a.currentPage == page.LogsPage { + // // bindings = append(bindings, logsKeyReturnKey) + // } + // if !a.app.CoderAgent.IsBusy() { + // // bindings = append(bindings, helpEsc) + // } + // + // a.help.SetBindings(bindings) + // + // overlay := a.help.View().String() + // row := lipgloss.Height(appView) / 2 + // row -= lipgloss.Height(overlay) / 2 + // col := lipgloss.Width(appView) / 2 + // col -= lipgloss.Width(overlay) / 2 + // appView = layout.PlaceOverlay( + // col, + // row, + // overlay, + // appView, + // true, + // ) + // } + // + // if a.showSessionDialog { + // overlay := a.sessionDialog.View().String() + // row := lipgloss.Height(appView) / 2 + // row -= lipgloss.Height(overlay) / 2 + // col := lipgloss.Width(appView) / 2 + // col -= lipgloss.Width(overlay) / 2 + // appView = layout.PlaceOverlay( + // col, + // row, + // overlay, + // appView, + // true, + // ) + // } + // + // if a.showModelDialog { + // overlay := a.modelDialog.View().String() + // row := lipgloss.Height(appView) / 2 + // row -= lipgloss.Height(overlay) / 2 + // col := lipgloss.Width(appView) / 2 + // col -= lipgloss.Width(overlay) / 2 + // appView = layout.PlaceOverlay( + // col, + // row, + // overlay, + // appView, + // true, + // ) + // } + // + // if a.showCommandDialog { + // overlay := a.commandDialog.View().String() + // row := lipgloss.Height(appView) / 2 + // row -= lipgloss.Height(overlay) / 2 + // col := lipgloss.Width(appView) / 2 + // col -= lipgloss.Width(overlay) / 2 + // appView = layout.PlaceOverlay( + // col, + // row, + // overlay, + // appView, + // true, + // ) + // } + // + // if a.showInitDialog { + // overlay := a.initDialog.View() + // appView = layout.PlaceOverlay( + // a.width/2-lipgloss.Width(overlay)/2, + // a.height/2-lipgloss.Height(overlay)/2, + // overlay, + // appView, + // true, + // ) + // } + // + // if a.showThemeDialog { + // overlay := a.themeDialog.View().String() + // row := lipgloss.Height(appView) / 2 + // row -= lipgloss.Height(overlay) / 2 + // col := lipgloss.Width(appView) / 2 + // col -= lipgloss.Width(overlay) / 2 + // appView = layout.PlaceOverlay( + // col, + // row, + // overlay, + // appView, + // true, + // ) + // } + // + // if a.showMultiArgumentsDialog { + // overlay := a.multiArgumentsDialog.View() + // row := lipgloss.Height(appView) / 2 + // row -= lipgloss.Height(overlay) / 2 + // col := lipgloss.Width(appView) / 2 + // col -= lipgloss.Width(overlay) / 2 + // appView = layout.PlaceOverlay( + // col, + // row, + // overlay, + // appView, + // true, + // ) + // } + t := theme.CurrentTheme() + if a.dialog.HasDialogs() { + layers := append( + []*lipgloss.Layer{ + lipgloss.NewLayer(appView), + }, + a.dialog.GetLayers()..., ) - } - - if a.showMultiArgumentsDialog { - overlay := a.multiArgumentsDialog.View() - row := lipgloss.Height(appView) / 2 - row -= lipgloss.Height(overlay) / 2 - col := lipgloss.Width(appView) / 2 - col -= lipgloss.Width(overlay) / 2 - appView = layout.PlaceOverlay( - col, - row, - overlay, - appView, - true, + canvas := lipgloss.NewCanvas( + layers..., ) + view := tea.NewView(canvas.Render()) + activeView := a.dialog.ActiveView() + view.SetBackgroundColor(t.Background()) + view.SetCursor(activeView.Cursor()) + return view } - return appView + view := tea.NewView(appView) + view.SetCursor(pageView.Cursor()) + view.SetBackgroundColor(t.Background()) + return view } func New(app *app.App) tea.Model { startPage := page.ChatPage model := &appModel{ - currentPage: startPage, - loadedPages: make(map[page.PageID]bool), - status: core.NewStatusCmp(app.LSPClients), - help: dialog.NewHelpCmp(), - quit: dialog.NewQuitCmp(), - sessionDialog: dialog.NewSessionDialogCmp(), - commandDialog: dialog.NewCommandDialogCmp(), - modelDialog: dialog.NewModelDialogCmp(), - permissions: dialog.NewPermissionDialogCmp(), - initDialog: dialog.NewInitDialogCmp(), - themeDialog: dialog.NewThemeDialogCmp(), - app: app, - commands: []dialog.Command{}, + currentPage: startPage, + app: app, + status: core.NewStatusCmp(app.LSPClients), + loadedPages: make(map[page.PageID]bool), + keyMap: DefaultKeyMap(), + + // help: dialog.NewHelpCmp(), + // sessionDialog: dialog.NewSessionDialogCmp(), + // commandDialog: dialog.NewCommandDialogCmp(), + // modelDialog: dialog.NewModelDialogCmp(), + // permissions: dialog.NewPermissionDialogCmp(), + // initDialog: dialog.NewInitDialogCmp(), + // themeDialog: dialog.NewThemeDialogCmp(), + // commands: []dialog.Command{}, pages: map[page.PageID]util.Model{ page.ChatPage: page.NewChatPage(app), page.LogsPage: page.NewLogsPage(), }, - filepicker: dialog.NewFilepickerCmp(app), - } + // filepicker: dialog.NewFilepickerCmp(app), - model.RegisterCommand(dialog.Command{ - ID: "init", - Title: "Initialize Project", - Description: "Create/Update the OpenCode.md memory file", - Handler: func(cmd dialog.Command) tea.Cmd { - prompt := `Please analyze this codebase and create a OpenCode.md file containing: -1. Build/lint/test commands - especially for running a single test -2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc. - -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. -If there's already a opencode.md, improve it. -If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.` - return tea.Batch( - util.CmdHandler(chat.SendMsg{ - Text: prompt, - }), - ) - }, - }) - - model.RegisterCommand(dialog.Command{ - ID: "compact", - Title: "Compact Session", - Description: "Summarize the current session and create a new one with the summary", - Handler: func(cmd dialog.Command) tea.Cmd { - return func() tea.Msg { - return startCompactSessionMsg{} - } - }, - }) - // Load custom commands - customCommands, err := dialog.LoadCustomCommands() - if err != nil { - logging.Warn("Failed to load custom commands", "error", err) - } else { - for _, cmd := range customCommands { - model.RegisterCommand(cmd) - } + // New dialog + dialog: dialogs.NewDialogCmp(), } + // model.RegisterCommand(dialog.Command{ + // ID: "init", + // Title: "Initialize Project", + // Description: "Create/Update the OpenCode.md memory file", + // Handler: func(cmd dialog.Command) tea.Cmd { + // prompt := `Please analyze this codebase and create a OpenCode.md file containing: + // 1. Build/lint/test commands - especially for running a single test + // 2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc. + // + // 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. + // If there's already a opencode.md, improve it. + // If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.` + // return tea.Batch( + // util.CmdHandler(chat.SendMsg{ + // Text: prompt, + // }), + // ) + // }, + // }) + // + // model.RegisterCommand(dialog.Command{ + // ID: "compact", + // Title: "Compact Session", + // Description: "Summarize the current session and create a new one with the summary", + // Handler: func(cmd dialog.Command) tea.Cmd { + // return func() tea.Msg { + // return startCompactSessionMsg{} + // } + // }, + // }) + // // Load custom commands + // customCommands, err := dialog.LoadCustomCommands() + // if err != nil { + // logging.Warn("Failed to load custom commands", "error", err) + // } else { + // for _, cmd := range customCommands { + // model.RegisterCommand(cmd) + // } + // } + return model } diff --git a/internal/tui/util/util.go b/internal/tui/util/util.go index ec658a0e65b649decf31fc4134183c2fa14925f7..8f7bb1bed15c184121cf5c0b16d9ba0cd98eb531 100644 --- a/internal/tui/util/util.go +++ b/internal/tui/util/util.go @@ -8,7 +8,7 @@ import ( type Model interface { tea.Model - tea.ViewModel + tea.Viewable } func CmdHandler(msg tea.Msg) tea.Cmd {