cspell.json 🔗
@@ -0,0 +1 @@
+{"flagWords":[],"version":"0.2","words":["opencode","charmbracelet","lipgloss","bubbletea"],"language":"en"}
Kujtim Hoxha created
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
internal/tui/components/chat/messages/messages.go | 10
internal/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
internal/tui/components/dialogs/commands/arguments.go | 16
internal/tui/components/dialogs/commands/commands.go | 135 +
internal/tui/components/dialogs/commands/item.go | 55
internal/tui/components/dialogs/commands/loader.go | 204 ++
internal/tui/components/dialogs/dialogs.go | 164 +
internal/tui/components/dialogs/keys.go | 37
internal/tui/components/dialogs/quit/keys.go | 59
internal/tui/components/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 | 1183 ++++++------
internal/tui/util/util.go | 2
40 files changed, 1,676 insertions(+), 855 deletions(-)
@@ -0,0 +1 @@
+{"flagWords":[],"version":"0.2","words":["opencode","charmbracelet","lipgloss","bubbletea"],"language":"en"}
@@ -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
@@ -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=
@@ -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 {
@@ -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())
@@ -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).
@@ -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
@@ -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
}
@@ -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
@@ -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 {
@@ -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...)
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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) {
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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
+}
@@ -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
+}
@@ -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
+}
@@ -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
+}
@@ -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
+}
@@ -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,
+ }
+}
@@ -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,
+ }
+}
@@ -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
}
@@ -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) {
@@ -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) {
@@ -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] {
@@ -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{}
+}
@@ -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()
@@ -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 {
@@ -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...)
}
@@ -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
}
@@ -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 {
@@ -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() {
@@ -8,7 +8,7 @@ import (
type Model interface {
tea.Model
- tea.ViewModel
+ tea.Viewable
}
func CmdHandler(msg tea.Msg) tea.Cmd {