summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authoradamdottv <[email protected]>2025-06-17 07:09:04 -0500
committeradamdottv <[email protected]>2025-06-17 07:09:04 -0500
commita5da5127faffacd7703fc0dde061ef1f490d3dce (patch)
treeab9c65d0eb45ab9fdc326187ca5881dc9b10936e
parentb5a4439704c70a17d661f1984bb030d5325d141a (diff)
downloadopencode-a5da5127faffacd7703fc0dde061ef1f490d3dce.tar.gz
opencode-a5da5127faffacd7703fc0dde061ef1f490d3dce.zip
chore: consolidate chat page into tui.go
-rw-r--r--packages/tui/internal/app/app.go15
-rw-r--r--packages/tui/internal/components/chat/editor.go13
-rw-r--r--packages/tui/internal/components/chat/messages.go21
-rw-r--r--packages/tui/internal/components/dialog/complete.go4
-rw-r--r--packages/tui/internal/components/dialog/models.go3
-rw-r--r--packages/tui/internal/components/dialog/permission.go4
-rw-r--r--packages/tui/internal/components/dialog/session.go3
-rw-r--r--packages/tui/internal/components/dialog/theme.go6
-rw-r--r--packages/tui/internal/components/list/list.go4
-rw-r--r--packages/tui/internal/components/status/status.go (renamed from packages/tui/internal/components/core/status.go)6
-rw-r--r--packages/tui/internal/layout/container.go56
-rw-r--r--packages/tui/internal/layout/flex.go3
-rw-r--r--packages/tui/internal/page/chat.go187
-rw-r--r--packages/tui/internal/page/page.go8
-rw-r--r--packages/tui/internal/state/state.go19
-rw-r--r--packages/tui/internal/styles/icons.go12
-rw-r--r--packages/tui/internal/tui/tui.go244
17 files changed, 236 insertions, 372 deletions
diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go
index f7481f9f4..8db93381a 100644
--- a/packages/tui/internal/app/app.go
+++ b/packages/tui/internal/app/app.go
@@ -11,7 +11,6 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/config"
- "github.com/sst/opencode/internal/state"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
"github.com/sst/opencode/pkg/client"
@@ -32,6 +31,14 @@ type App struct {
Commands commands.Registry
}
+type SessionSelectedMsg = *client.SessionInfo
+type ModelSelectedMsg struct {
+ Provider client.ProviderInfo
+ Model client.ModelInfo
+}
+type SessionClearedMsg struct{}
+type CompactSessionMsg struct{}
+
func New(
ctx context.Context,
version string,
@@ -118,7 +125,7 @@ func (a *App) InitializeProvider() tea.Cmd {
}
// TODO: handle no provider or model setup, yet
- return state.ModelSelectedMsg{
+ return ModelSelectedMsg{
Provider: *currentProvider,
Model: *currentModel,
}
@@ -167,7 +174,7 @@ func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
}
a.Session = session
- cmds = append(cmds, util.CmdHandler(state.SessionSelectedMsg(session)))
+ cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
go func() {
response, err := a.Client.PostSessionInitialize(ctx, client.PostSessionInitializeJSONRequestBody{
@@ -236,7 +243,7 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
return nil
}
a.Session = session
- cmds = append(cmds, util.CmdHandler(state.SessionSelectedMsg(session)))
+ cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
}
// TODO: Handle attachments when API supports them
diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go
index 349087fbe..a2d33f172 100644
--- a/packages/tui/internal/components/chat/editor.go
+++ b/packages/tui/internal/components/chat/editor.go
@@ -16,12 +16,17 @@ import (
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/image"
- "github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
+type EditorComponent interface {
+ tea.Model
+ tea.ViewModel
+ Value() string
+}
+
type editorComponent struct {
width int
height int
@@ -99,7 +104,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
switch msg := msg.(type) {
- case dialog.ThemeChangedMsg:
+ case dialog.ThemeSelectedMsg:
m.textarea = createTextArea(&m.textarea)
m.spinner = createSpinner()
return m, m.spinner.Tick
@@ -434,11 +439,11 @@ func createSpinner() spinner.Model {
)
}
-func (m *editorComponent) GetValue() string {
+func (m *editorComponent) Value() string {
return m.textarea.Value()
}
-func NewEditorComponent(app *app.App) layout.ModelWithView {
+func NewEditorComponent(app *app.App) EditorComponent {
s := createSpinner()
ta := createTextArea(nil)
diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go
index d0d839845..37e3380ca 100644
--- a/packages/tui/internal/components/chat/messages.go
+++ b/packages/tui/internal/components/chat/messages.go
@@ -12,12 +12,16 @@ import (
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/layout"
- "github.com/sst/opencode/internal/state"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/pkg/client"
)
+type MessagesComponent interface {
+ tea.Model
+ tea.ViewModel
+}
+
type messagesComponent struct {
app *app.App
width, height int
@@ -69,7 +73,7 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.viewport.GotoBottom()
m.tail = true
return m, nil
- case dialog.ThemeChangedMsg:
+ case dialog.ThemeSelectedMsg:
m.cache.Clear()
m.renderView()
return m, nil
@@ -77,12 +81,12 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.showToolResults = !m.showToolResults
m.renderView()
return m, nil
- case state.SessionSelectedMsg:
+ case app.SessionSelectedMsg:
m.cache.Clear()
cmd := m.Reload()
m.viewport.GotoBottom()
return m, cmd
- case state.SessionClearedMsg:
+ case app.SessionClearedMsg:
m.cache.Clear()
cmd := m.Reload()
return m, cmd
@@ -101,7 +105,12 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.tail {
m.viewport.GotoBottom()
}
- case state.StateUpdatedMsg:
+ case client.EventSessionUpdated:
+ m.renderView()
+ if m.tail {
+ m.viewport.GotoBottom()
+ }
+ case client.EventMessageUpdated:
m.renderView()
if m.tail {
m.viewport.GotoBottom()
@@ -389,7 +398,7 @@ func (m *messagesComponent) Reload() tea.Cmd {
}
}
-func NewMessagesComponent(app *app.App) layout.ModelWithView {
+func NewMessagesComponent(app *app.App) MessagesComponent {
customSpinner := spinner.Spinner{
Frames: []string{" ", "┃", "┃"},
FPS: time.Second / 3,
diff --git a/packages/tui/internal/components/dialog/complete.go b/packages/tui/internal/components/dialog/complete.go
index d87a331cf..ca86b00e6 100644
--- a/packages/tui/internal/components/dialog/complete.go
+++ b/packages/tui/internal/components/dialog/complete.go
@@ -6,7 +6,6 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/components/list"
- "github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
@@ -76,7 +75,8 @@ type CompletionDialogCompleteItemMsg struct {
type CompletionDialogCloseMsg struct{}
type CompletionDialog interface {
- layout.ModelWithView
+ tea.Model
+ tea.ViewModel
SetWidth(width int)
IsEmpty() bool
SetProvider(provider CompletionProvider)
diff --git a/packages/tui/internal/components/dialog/models.go b/packages/tui/internal/components/dialog/models.go
index 616347088..dfb11dffb 100644
--- a/packages/tui/internal/components/dialog/models.go
+++ b/packages/tui/internal/components/dialog/models.go
@@ -13,7 +13,6 @@ import (
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/layout"
- "github.com/sst/opencode/internal/state"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
@@ -115,7 +114,7 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Sequence(
util.CmdHandler(modal.CloseModalMsg{}),
util.CmdHandler(
- state.ModelSelectedMsg{
+ app.ModelSelectedMsg{
Provider: m.provider,
Model: models[m.selectedIdx],
}),
diff --git a/packages/tui/internal/components/dialog/permission.go b/packages/tui/internal/components/dialog/permission.go
index ba82c876b..1f573e59d 100644
--- a/packages/tui/internal/components/dialog/permission.go
+++ b/packages/tui/internal/components/dialog/permission.go
@@ -6,7 +6,6 @@ import (
"github.com/charmbracelet/bubbles/v2/viewport"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
- "github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
@@ -30,7 +29,8 @@ type PermissionResponseMsg struct {
// PermissionDialogComponent interface for permission dialog component
type PermissionDialogComponent interface {
- layout.ModelWithView
+ tea.Model
+ tea.ViewModel
// SetPermissions(permission permission.PermissionRequest) tea.Cmd
}
diff --git a/packages/tui/internal/components/dialog/session.go b/packages/tui/internal/components/dialog/session.go
index 48278d85e..b59ebe3d5 100644
--- a/packages/tui/internal/components/dialog/session.go
+++ b/packages/tui/internal/components/dialog/session.go
@@ -8,7 +8,6 @@ import (
"github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/layout"
- "github.com/sst/opencode/internal/state"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
@@ -69,7 +68,7 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
s.selectedSessionID = selectedSession.Id
return s, tea.Sequence(
util.CmdHandler(modal.CloseModalMsg{}),
- util.CmdHandler(state.SessionSelectedMsg(&selectedSession)),
+ util.CmdHandler(app.SessionSelectedMsg(&selectedSession)),
)
}
}
diff --git a/packages/tui/internal/components/dialog/theme.go b/packages/tui/internal/components/dialog/theme.go
index 50472428b..63135bc81 100644
--- a/packages/tui/internal/components/dialog/theme.go
+++ b/packages/tui/internal/components/dialog/theme.go
@@ -10,8 +10,8 @@ import (
"github.com/sst/opencode/internal/util"
)
-// ThemeChangedMsg is sent when the theme is changed
-type ThemeChangedMsg struct {
+// ThemeSelectedMsg is sent when the theme is changed
+type ThemeSelectedMsg struct {
ThemeName string
}
@@ -75,7 +75,7 @@ func (t *themeDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return t, tea.Sequence(
util.CmdHandler(modal.CloseModalMsg{}),
- util.CmdHandler(ThemeChangedMsg{ThemeName: selectedTheme}),
+ util.CmdHandler(ThemeSelectedMsg{ThemeName: selectedTheme}),
)
}
}
diff --git a/packages/tui/internal/components/list/list.go b/packages/tui/internal/components/list/list.go
index fea77e902..cefcaabef 100644
--- a/packages/tui/internal/components/list/list.go
+++ b/packages/tui/internal/components/list/list.go
@@ -4,7 +4,6 @@ import (
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
- "github.com/sst/opencode/internal/layout"
)
type ListItem interface {
@@ -12,7 +11,8 @@ type ListItem interface {
}
type List[T ListItem] interface {
- layout.ModelWithView
+ tea.Model
+ tea.ViewModel
SetMaxWidth(maxWidth int)
GetSelectedItem() (item T, idx int)
SetItems(items []T)
diff --git a/packages/tui/internal/components/core/status.go b/packages/tui/internal/components/status/status.go
index 9540c6c2b..01a2659c6 100644
--- a/packages/tui/internal/components/core/status.go
+++ b/packages/tui/internal/components/status/status.go
@@ -1,4 +1,4 @@
-package core
+package status
import (
"fmt"
@@ -7,13 +7,13 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/app"
- "github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
type StatusComponent interface {
- layout.ModelWithView
+ tea.Model
+ tea.ViewModel
}
type statusComponent struct {
diff --git a/packages/tui/internal/layout/container.go b/packages/tui/internal/layout/container.go
index b1310f498..14434124a 100644
--- a/packages/tui/internal/layout/container.go
+++ b/packages/tui/internal/layout/container.go
@@ -6,20 +6,14 @@ import (
"github.com/sst/opencode/internal/theme"
)
-type ModelWithView interface {
+type Container interface {
tea.Model
tea.ViewModel
-}
-
-type Container interface {
- ModelWithView
Sizeable
- Focus()
- Blur()
+ Focusable
MaxWidth() int
Alignment() lipgloss.Position
GetPosition() (x, y int)
- GetContent() ModelWithView
}
type container struct {
@@ -28,7 +22,7 @@ type container struct {
x int
y int
- content ModelWithView
+ content tea.ViewModel
paddingTop int
paddingRight int
@@ -48,13 +42,19 @@ type container struct {
}
func (c *container) Init() tea.Cmd {
- return c.content.Init()
+ if model, ok := c.content.(tea.Model); ok {
+ return model.Init()
+ }
+ return nil
}
func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- u, cmd := c.content.Update(msg)
- c.content = u.(ModelWithView)
- return c, cmd
+ if model, ok := c.content.(tea.Model); ok {
+ u, cmd := model.Update(msg)
+ c.content = u.(tea.ViewModel)
+ return c, cmd
+ }
+ return c, nil
}
func (c *container) View() string {
@@ -156,21 +156,28 @@ func (c *container) Alignment() lipgloss.Position {
}
// Focus sets the container as focused
-func (c *container) Focus() {
+func (c *container) Focus() tea.Cmd {
c.focused = true
- // Pass focus to content if it supports it
- if focusable, ok := c.content.(interface{ Focus() }); ok {
- focusable.Focus()
+ if focusable, ok := c.content.(Focusable); ok {
+ return focusable.Focus()
}
+ return nil
}
// Blur removes focus from the container
-func (c *container) Blur() {
+func (c *container) Blur() tea.Cmd {
c.focused = false
- // Remove focus from content if it supports it
- if blurable, ok := c.content.(interface{ Blur() }); ok {
- blurable.Blur()
+ if blurable, ok := c.content.(Focusable); ok {
+ return blurable.Blur()
}
+ return nil
+}
+
+func (c *container) IsFocused() bool {
+ if blurable, ok := c.content.(Focusable); ok {
+ return blurable.IsFocused()
+ }
+ return c.focused
}
// GetPosition returns the x, y coordinates of the container
@@ -178,14 +185,9 @@ func (c *container) GetPosition() (x, y int) {
return c.x, c.y
}
-// GetContent returns the content of the container
-func (c *container) GetContent() ModelWithView {
- return c.content
-}
-
type ContainerOption func(*container)
-func NewContainer(content ModelWithView, options ...ContainerOption) Container {
+func NewContainer(content tea.ViewModel, options ...ContainerOption) Container {
c := &container{
content: content,
borderStyle: lipgloss.NormalBorder(),
diff --git a/packages/tui/internal/layout/flex.go b/packages/tui/internal/layout/flex.go
index 3a5c3aa71..0d337be2a 100644
--- a/packages/tui/internal/layout/flex.go
+++ b/packages/tui/internal/layout/flex.go
@@ -25,7 +25,8 @@ func FlexPaneSizeFixed(size int) FlexPaneSize {
}
type FlexLayout interface {
- ModelWithView
+ tea.Model
+ tea.ViewModel
Sizeable
SetPanes(panes []Container) tea.Cmd
SetPaneSizes(sizes []FlexPaneSize) tea.Cmd
diff --git a/packages/tui/internal/page/chat.go b/packages/tui/internal/page/chat.go
deleted file mode 100644
index b96f52b2c..000000000
--- a/packages/tui/internal/page/chat.go
+++ /dev/null
@@ -1,187 +0,0 @@
-package page
-
-import (
- "context"
-
- "github.com/charmbracelet/bubbles/v2/key"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/sst/opencode/internal/app"
- "github.com/sst/opencode/internal/completions"
- "github.com/sst/opencode/internal/components/chat"
- "github.com/sst/opencode/internal/components/dialog"
- "github.com/sst/opencode/internal/layout"
- "github.com/sst/opencode/internal/util"
-)
-
-var ChatPage PageID = "chat"
-
-type chatPage struct {
- app *app.App
- editor layout.Container
- messages layout.Container
- layout layout.FlexLayout
- completionDialog dialog.CompletionDialog
- completionManager *completions.CompletionManager
- showCompletionDialog bool
-}
-
-type ChatKeyMap struct {
- Cancel key.Binding
- ToggleTools key.Binding
- ShowCompletionDialog key.Binding
-}
-
-var keyMap = ChatKeyMap{
- Cancel: key.NewBinding(
- key.WithKeys("esc"),
- key.WithHelp("esc", "cancel"),
- ),
- ToggleTools: key.NewBinding(
- key.WithKeys("ctrl+h"),
- key.WithHelp("ctrl+h", "toggle tools"),
- ),
- ShowCompletionDialog: key.NewBinding(
- key.WithKeys("/"),
- key.WithHelp("/", "Complete"),
- ),
-}
-
-func (p *chatPage) Init() tea.Cmd {
- cmds := []tea.Cmd{
- p.layout.Init(),
- }
- cmds = append(cmds, p.completionDialog.Init())
- return tea.Batch(cmds...)
-}
-
-func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmds []tea.Cmd
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- cmd := p.layout.SetSize(msg.Width, msg.Height)
- cmds = append(cmds, cmd)
- case chat.SendMsg:
- p.showCompletionDialog = false
- cmd := p.sendMessage(msg.Text, msg.Attachments)
- if cmd != nil {
- return p, cmd
- }
- case dialog.CompletionDialogCloseMsg:
- p.showCompletionDialog = false
- case tea.KeyMsg:
- switch msg.String() {
- case "ctrl+c":
- _, cmd := p.editor.Update(msg)
- if cmd != nil {
- return p, cmd
- }
- }
-
- switch {
- case key.Matches(msg, keyMap.ShowCompletionDialog):
- p.showCompletionDialog = true
- // Continue sending keys to layout->chat
- case key.Matches(msg, keyMap.Cancel):
- if p.app.Session.Id != "" {
- // Cancel the current session's generation process
- // This allows users to interrupt long-running operations
- p.app.Cancel(context.Background(), p.app.Session.Id)
- return p, nil
- }
- case key.Matches(msg, keyMap.ToggleTools):
- return p, util.CmdHandler(chat.ToggleToolMessagesMsg{})
- }
- }
-
- if p.showCompletionDialog {
- // Get the current text from the editor to determine which provider to use
- editorModel := p.editor.GetContent().(interface{ GetValue() string })
- currentInput := editorModel.GetValue()
- provider := p.completionManager.GetProvider(currentInput)
- p.completionDialog.SetProvider(provider)
-
- context, contextCmd := p.completionDialog.Update(msg)
- p.completionDialog = context.(dialog.CompletionDialog)
- cmds = append(cmds, contextCmd)
-
- // Doesn't forward event if enter key is pressed
- if keyMsg, ok := msg.(tea.KeyMsg); ok {
- if keyMsg.String() == "enter" {
- return p, tea.Batch(cmds...)
- }
- }
- }
-
- u, cmd := p.layout.Update(msg)
- cmds = append(cmds, cmd)
- p.layout = u.(layout.FlexLayout)
- return p, tea.Batch(cmds...)
-}
-
-func (p *chatPage) sendMessage(text string, attachments []app.Attachment) tea.Cmd {
- var cmds []tea.Cmd
- cmd := p.app.SendChatMessage(context.Background(), text, attachments)
- cmds = append(cmds, cmd)
- return tea.Batch(cmds...)
-}
-
-func (p *chatPage) SetSize(width, height int) tea.Cmd {
- return p.layout.SetSize(width, height)
-}
-
-func (p *chatPage) GetSize() (int, int) {
- return p.layout.GetSize()
-}
-
-func (p *chatPage) View() string {
- layoutView := p.layout.View()
-
- if p.showCompletionDialog {
- editorWidth, _ := p.editor.GetSize()
- editorX, editorY := p.editor.GetPosition()
-
- p.completionDialog.SetWidth(editorWidth)
- overlay := p.completionDialog.View()
-
- layoutView = layout.PlaceOverlay(
- editorX,
- editorY-lipgloss.Height(overlay)+2,
- overlay,
- layoutView,
- )
- }
-
- return layoutView
-}
-
-func NewChatPage(app *app.App) layout.ModelWithView {
- completionManager := completions.NewCompletionManager(app)
- initialProvider := completionManager.GetProvider("")
- completionDialog := dialog.NewCompletionDialogComponent(initialProvider)
-
- messagesContainer := layout.NewContainer(
- chat.NewMessagesComponent(app),
- )
- editorContainer := layout.NewContainer(
- chat.NewEditorComponent(app),
- layout.WithMaxWidth(layout.Current.Container.Width),
- layout.WithAlignCenter(),
- )
-
- return &chatPage{
- app: app,
- editor: editorContainer,
- messages: messagesContainer,
- completionDialog: completionDialog,
- completionManager: completionManager,
- layout: layout.NewFlexLayout(
- layout.WithPanes(messagesContainer, editorContainer),
- layout.WithDirection(layout.FlexDirectionVertical),
- layout.WithPaneSizes(
- layout.FlexPaneSizeGrow,
- layout.FlexPaneSizeFixed(6),
- ),
- ),
- }
-}
diff --git a/packages/tui/internal/page/page.go b/packages/tui/internal/page/page.go
deleted file mode 100644
index 482df5fd7..000000000
--- a/packages/tui/internal/page/page.go
+++ /dev/null
@@ -1,8 +0,0 @@
-package page
-
-type PageID string
-
-// PageChangeMsg is used to change the current page
-type PageChangeMsg struct {
- ID PageID
-}
diff --git a/packages/tui/internal/state/state.go b/packages/tui/internal/state/state.go
deleted file mode 100644
index c5322e7b2..000000000
--- a/packages/tui/internal/state/state.go
+++ /dev/null
@@ -1,19 +0,0 @@
-package state
-
-import (
- "github.com/sst/opencode/pkg/client"
-)
-
-type SessionSelectedMsg = *client.SessionInfo
-type ModelSelectedMsg struct {
- Provider client.ProviderInfo
- Model client.ModelInfo
-}
-
-type SessionClearedMsg struct{}
-type CompactSessionMsg struct{}
-
-// TODO: remove
-type StateUpdatedMsg struct {
- State map[string]any
-}
diff --git a/packages/tui/internal/styles/icons.go b/packages/tui/internal/styles/icons.go
deleted file mode 100644
index 8ff5fe8bf..000000000
--- a/packages/tui/internal/styles/icons.go
+++ /dev/null
@@ -1,12 +0,0 @@
-package styles
-
-const (
- OpenCodeIcon string = "◍"
-
- ErrorIcon string = "ⓔ"
- WarningIcon string = "ⓦ"
- InfoIcon string = "ⓘ"
- HintIcon string = "ⓗ"
- SpinnerIcon string = "⟳"
- DocumentIcon string = "🖼"
-)
diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go
index 1c93f116b..c5aae2596 100644
--- a/packages/tui/internal/tui/tui.go
+++ b/packages/tui/internal/tui/tui.go
@@ -12,12 +12,12 @@ import (
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands"
- "github.com/sst/opencode/internal/components/core"
+ "github.com/sst/opencode/internal/completions"
+ "github.com/sst/opencode/internal/components/chat"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/components/modal"
+ "github.com/sst/opencode/internal/components/status"
"github.com/sst/opencode/internal/layout"
- "github.com/sst/opencode/internal/page"
- "github.com/sst/opencode/internal/state"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
@@ -25,14 +25,38 @@ import (
)
type appModel struct {
- width, height int
- currentPage page.PageID
- previousPage page.PageID
- pages map[page.PageID]layout.ModelWithView
- loadedPages map[page.PageID]bool
- status core.StatusComponent
- app *app.App
- modal layout.Modal
+ width, height int
+ status status.StatusComponent
+ app *app.App
+ modal layout.Modal
+ editorContainer layout.Container
+ editor chat.EditorComponent
+ messagesContainer layout.Container
+ layout layout.FlexLayout
+ completionDialog dialog.CompletionDialog
+ completionManager *completions.CompletionManager
+ showCompletionDialog bool
+}
+
+type ChatKeyMap struct {
+ Cancel key.Binding
+ ToggleTools key.Binding
+ ShowCompletionDialog key.Binding
+}
+
+var keyMap = ChatKeyMap{
+ Cancel: key.NewBinding(
+ key.WithKeys("esc"),
+ key.WithHelp("esc", "cancel"),
+ ),
+ ToggleTools: key.NewBinding(
+ key.WithKeys("ctrl+h"),
+ key.WithHelp("ctrl+h", "toggle tools"),
+ ),
+ ShowCompletionDialog: key.NewBinding(
+ key.WithKeys("/"),
+ key.WithHelp("/", "Complete"),
+ ),
}
func (a appModel) Init() tea.Cmd {
@@ -43,12 +67,9 @@ func (a appModel) Init() tea.Cmd {
cmds = append(cmds, tea.SetBackgroundColor(t.Background()))
cmds = append(cmds, tea.RequestBackgroundColor)
- cmd := a.pages[a.currentPage].Init()
- a.loadedPages[a.currentPage] = true
- cmds = append(cmds, cmd)
-
- cmd = a.status.Init()
- cmds = append(cmds, cmd)
+ cmds = append(cmds, a.layout.Init())
+ cmds = append(cmds, a.completionDialog.Init())
+ cmds = append(cmds, a.status.Init())
// Check if we should show the init dialog
cmds = append(cmds, func() tea.Msg {
@@ -59,23 +80,6 @@ func (a appModel) Init() tea.Cmd {
return tea.Batch(cmds...)
}
-func (a appModel) updateAllPages(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmds []tea.Cmd
- var cmd tea.Cmd
-
- for id := range a.pages {
- updated, cmd := a.pages[id].Update(msg)
- a.pages[id] = updated.(layout.ModelWithView)
- cmds = append(cmds, cmd)
- }
-
- s, cmd := a.status.Update(msg)
- cmds = append(cmds, cmd)
- a.status = s.(core.StatusComponent)
-
- return a, tea.Batch(cmds...)
-}
-
func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
@@ -97,6 +101,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, tea.Quit
}
+ // TODO: do we need this?
// don't send commands to the modal
for _, cmdDef := range a.app.Commands {
if key.Matches(msg, cmdDef.KeyBinding) {
@@ -128,6 +133,14 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
switch msg := msg.(type) {
+ case chat.SendMsg:
+ a.showCompletionDialog = false
+ cmd := a.sendMessage(msg.Text, msg.Attachments)
+ if cmd != nil {
+ return a, cmd
+ }
+ case dialog.CompletionDialogCloseMsg:
+ a.showCompletionDialog = false
case commands.ExecuteCommandMsg:
switch msg.Name {
case "quit":
@@ -135,7 +148,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "new":
a.app.Session = &client.SessionInfo{}
a.app.Messages = []client.MessageInfo{}
- cmds = append(cmds, util.CmdHandler(state.SessionClearedMsg{}))
+ cmds = append(cmds, util.CmdHandler(app.SessionClearedMsg{}))
case "sessions":
sessionDialog := dialog.NewSessionDialog(a.app)
a.modal = sessionDialog
@@ -173,28 +186,23 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
BackgroundIsDark: msg.IsDark(),
}
- case cursor.BlinkMsg:
- return a.updateAllPages(msg)
-
- case spinner.TickMsg:
- return a.updateAllPages(msg)
-
case client.EventSessionUpdated:
if msg.Properties.Info.Id == a.app.Session.Id {
a.app.Session = &msg.Properties.Info
- return a.updateAllPages(state.StateUpdatedMsg{State: nil})
}
case client.EventMessageUpdated:
if msg.Properties.Info.Metadata.SessionID == a.app.Session.Id {
+ exists := false
for i, m := range a.app.Messages {
if m.Id == msg.Properties.Info.Id {
a.app.Messages[i] = msg.Properties.Info
- return a.updateAllPages(state.StateUpdatedMsg{State: nil})
+ exists = true
}
}
- a.app.Messages = append(a.app.Messages, msg.Properties.Info)
- return a.updateAllPages(state.StateUpdatedMsg{State: nil})
+ if !exists {
+ a.app.Messages = append(a.app.Messages, msg.Properties.Info)
+ }
}
case tea.WindowSizeMsg:
@@ -212,18 +220,20 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
},
}
+ // Update status
s, cmd := a.status.Update(msg)
- a.status = s.(core.StatusComponent)
+ a.status = s.(status.StatusComponent)
if cmd != nil {
cmds = append(cmds, cmd)
}
- updated, cmd := a.pages[a.currentPage].Update(msg)
- a.pages[a.currentPage] = updated.(layout.ModelWithView)
+ // Update chat layout
+ cmd = a.layout.SetSize(msg.Width, msg.Height)
if cmd != nil {
cmds = append(cmds, cmd)
}
+ // Update modal if present
if a.modal != nil {
s, cmd := a.modal.Update(msg)
a.modal = s.(layout.Modal)
@@ -234,35 +244,32 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, tea.Batch(cmds...)
- case page.PageChangeMsg:
- return a, a.moveToPage(msg.ID)
-
- case state.SessionSelectedMsg:
+ case app.SessionSelectedMsg:
a.app.Session = msg
a.app.Messages, _ = a.app.ListMessages(context.Background(), msg.Id)
- return a.updateAllPages(msg)
- case state.ModelSelectedMsg:
+ case app.ModelSelectedMsg:
a.app.Provider = &msg.Provider
a.app.Model = &msg.Model
a.app.Config.Provider = msg.Provider.Id
a.app.Config.Model = msg.Model.Id
a.app.SaveConfig()
- return a.updateAllPages(msg)
- case dialog.ThemeChangedMsg:
+ case dialog.ThemeSelectedMsg:
a.app.Config.Theme = msg.ThemeName
a.app.SaveConfig()
- updated, cmd := a.pages[a.currentPage].Update(msg)
- a.pages[a.currentPage] = updated.(layout.ModelWithView)
+ // Update layout
+ u, cmd := a.layout.Update(msg)
+ a.layout = u.(layout.FlexLayout)
if cmd != nil {
cmds = append(cmds, cmd)
}
+ // Update status
s, cmd := a.status.Update(msg)
cmds = append(cmds, cmd)
- a.status = s.(core.StatusComponent)
+ a.status = s.(status.StatusComponent)
t := theme.CurrentTheme()
cmds = append(cmds, tea.SetBackgroundColor(t.Background()))
@@ -272,13 +279,28 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.String() {
// give the editor a chance to clear input
case "ctrl+c":
- updated, cmd := a.pages[a.currentPage].Update(msg)
- a.pages[a.currentPage] = updated.(layout.ModelWithView)
+ _, cmd := a.editorContainer.Update(msg)
if cmd != nil {
return a, cmd
}
}
+ // Handle chat-specific keys
+ switch {
+ case key.Matches(msg, keyMap.ShowCompletionDialog):
+ a.showCompletionDialog = true
+ // Continue sending keys to layout->chat
+ case key.Matches(msg, keyMap.Cancel):
+ if a.app.Session.Id != "" {
+ // Cancel the current session's generation process
+ // This allows users to interrupt long-running operations
+ a.app.Cancel(context.Background(), a.app.Session.Id)
+ return a, nil
+ }
+ case key.Matches(msg, keyMap.ToggleTools):
+ return a, util.CmdHandler(chat.ToggleToolMessagesMsg{})
+ }
+
// First, check for modal triggers from the command registry
if a.modal == nil {
for _, cmdDef := range a.app.Commands {
@@ -291,40 +313,64 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
+ if a.showCompletionDialog {
+ currentInput := a.editor.Value()
+ provider := a.completionManager.GetProvider(currentInput)
+ a.completionDialog.SetProvider(provider)
+
+ context, contextCmd := a.completionDialog.Update(msg)
+ a.completionDialog = context.(dialog.CompletionDialog)
+ cmds = append(cmds, contextCmd)
+
+ // Doesn't forward event if enter key is pressed
+ if keyMsg, ok := msg.(tea.KeyMsg); ok {
+ if keyMsg.String() == "enter" {
+ return a, tea.Batch(cmds...)
+ }
+ }
+ }
+
// update status bar
s, cmd := a.status.Update(msg)
cmds = append(cmds, cmd)
- a.status = s.(core.StatusComponent)
+ a.status = s.(status.StatusComponent)
- // update current page
- updated, cmd := a.pages[a.currentPage].Update(msg)
- a.pages[a.currentPage] = updated.(layout.ModelWithView)
+ // update chat layout
+ u, cmd := a.layout.Update(msg)
+ a.layout = u.(layout.FlexLayout)
cmds = append(cmds, cmd)
return a, tea.Batch(cmds...)
}
-func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
+func (a *appModel) sendMessage(text string, attachments []app.Attachment) tea.Cmd {
var cmds []tea.Cmd
- if _, ok := a.loadedPages[pageID]; !ok {
- cmd := a.pages[pageID].Init()
- cmds = append(cmds, cmd)
- a.loadedPages[pageID] = true
- }
- a.previousPage = a.currentPage
- a.currentPage = pageID
- if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
- cmd := sizable.SetSize(a.width, a.height)
- cmds = append(cmds, cmd)
- }
-
+ cmd := a.app.SendChatMessage(context.Background(), text, attachments)
+ cmds = append(cmds, cmd)
return tea.Batch(cmds...)
}
func (a appModel) View() string {
+ layoutView := a.layout.View()
+
+ if a.showCompletionDialog {
+ editorWidth, _ := a.editorContainer.GetSize()
+ editorX, editorY := a.editorContainer.GetPosition()
+
+ a.completionDialog.SetWidth(editorWidth)
+ overlay := a.completionDialog.View()
+
+ layoutView = layout.PlaceOverlay(
+ editorX,
+ editorY-lipgloss.Height(overlay)+2,
+ overlay,
+ layoutView,
+ )
+ }
+
components := []string{
- a.pages[a.currentPage].View(),
+ layoutView,
+ a.status.View(),
}
- components = append(components, a.status.View())
appView := lipgloss.JoinVertical(lipgloss.Top, components...)
if a.modal != nil {
@@ -335,15 +381,37 @@ func (a appModel) View() string {
}
func NewModel(app *app.App) tea.Model {
- startPage := page.ChatPage
+ completionManager := completions.NewCompletionManager(app)
+ initialProvider := completionManager.GetProvider("")
+ completionDialog := dialog.NewCompletionDialogComponent(initialProvider)
+
+ messagesContainer := layout.NewContainer(
+ chat.NewMessagesComponent(app),
+ )
+ editor := chat.NewEditorComponent(app)
+ editorContainer := layout.NewContainer(
+ editor,
+ layout.WithMaxWidth(layout.Current.Container.Width),
+ layout.WithAlignCenter(),
+ )
+
model := &appModel{
- currentPage: startPage,
- loadedPages: make(map[page.PageID]bool),
- status: core.NewStatusCmp(app),
- app: app,
- pages: map[page.PageID]layout.ModelWithView{
- page.ChatPage: page.NewChatPage(app),
- },
+ status: status.NewStatusCmp(app),
+ app: app,
+ editorContainer: editorContainer,
+ editor: editor,
+ messagesContainer: messagesContainer,
+ completionDialog: completionDialog,
+ completionManager: completionManager,
+ showCompletionDialog: false,
+ layout: layout.NewFlexLayout(
+ layout.WithPanes(messagesContainer, editorContainer),
+ layout.WithDirection(layout.FlexDirectionVertical),
+ layout.WithPaneSizes(
+ layout.FlexPaneSizeGrow,
+ layout.FlexPaneSizeFixed(6),
+ ),
+ ),
}
return model