summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authoradamdottv <[email protected]>2025-06-13 09:57:54 -0500
committeradamdottv <[email protected]>2025-06-13 09:57:54 -0500
commit5706c6ad3add2ad4eb6c3d152f0fa72b701027c4 (patch)
treeaaa8b3c9f7ae66588a258d3c517b331c980a4d4d
parente8e03c895aa5fb215302ece625e9569397c9064c (diff)
downloadopencode-5706c6ad3add2ad4eb6c3d152f0fa72b701027c4.tar.gz
opencode-5706c6ad3add2ad4eb6c3d152f0fa72b701027c4.zip
wip: refactoring tui
-rw-r--r--packages/tui/internal/commands/command.go25
-rw-r--r--packages/tui/internal/components/chat/editor.go10
-rw-r--r--packages/tui/internal/components/chat/message.go16
-rw-r--r--packages/tui/internal/tui/tui.go178
4 files changed, 147 insertions, 82 deletions
diff --git a/packages/tui/internal/commands/command.go b/packages/tui/internal/commands/command.go
new file mode 100644
index 000000000..c2b8f2eae
--- /dev/null
+++ b/packages/tui/internal/commands/command.go
@@ -0,0 +1,25 @@
+package commands
+
+import (
+ "github.com/charmbracelet/bubbles/v2/key"
+)
+
+// Command represents a user-triggerable action.
+type Command struct {
+ // Name is the identifier used for slash commands (e.g., "new").
+ Name string
+ // Description is a short explanation of what the command does.
+ Description string
+ // KeyBinding is the keyboard shortcut to trigger this command.
+ KeyBinding key.Binding
+}
+
+// Registry holds all the available commands.
+type Registry struct {
+ Commands map[string]Command
+}
+
+// ExecuteCommandMsg is a message sent when a command should be executed.
+type ExecuteCommandMsg struct {
+ Name string
+} \ No newline at end of file
diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go
index 24cb588ed..7ba961577 100644
--- a/packages/tui/internal/components/chat/editor.go
+++ b/packages/tui/internal/components/chat/editor.go
@@ -13,6 +13,7 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/app"
+ "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"
@@ -365,7 +366,7 @@ func (m *editorComponent) openEditor(value string) tea.Cmd {
}
func (m *editorComponent) send() tea.Cmd {
- value := m.textarea.Value()
+ value := strings.TrimSpace(m.textarea.Value())
m.textarea.Reset()
attachments := m.attachments
@@ -382,6 +383,13 @@ func (m *editorComponent) send() tea.Cmd {
if value == "" {
return nil
}
+
+ // Check for slash command
+ if strings.HasPrefix(value, "/") {
+ commandName := strings.TrimPrefix(value, "/")
+ return util.CmdHandler(commands.ExecuteCommandMsg{Name: commandName})
+ }
+
return tea.Batch(
util.CmdHandler(SendMsg{
Text: value,
diff --git a/packages/tui/internal/components/chat/message.go b/packages/tui/internal/components/chat/message.go
index 068fca1d6..44aceea93 100644
--- a/packages/tui/internal/components/chat/message.go
+++ b/packages/tui/internal/components/chat/message.go
@@ -230,7 +230,7 @@ func renderText(message client.MessageInfo, text string, author string) string {
case client.Assistant:
return renderContentBlock(content,
WithAlign(lipgloss.Left),
- WithBorderColor(t.Primary()),
+ WithBorderColor(t.Accent()),
)
}
return ""
@@ -250,8 +250,12 @@ func renderToolInvocation(
outerWidth := layout.Current.Container.Width
innerWidth := outerWidth - 6
paddingTop := 0
+ paddingBottom := 0
if showResult {
paddingTop = 1
+ if result == nil || *result == "" {
+ paddingBottom = 1
+ }
}
t := theme.CurrentTheme()
@@ -259,6 +263,7 @@ func renderToolInvocation(
Width(outerWidth).
Background(t.BackgroundSubtle()).
PaddingTop(paddingTop).
+ PaddingBottom(paddingBottom).
PaddingLeft(2).
PaddingRight(2).
BorderLeft(true).
@@ -301,10 +306,17 @@ func renderToolInvocation(
if e, ok := metadata.Get("error"); ok && e.(bool) == true {
if m, ok := metadata.Get("message"); ok {
body = "" // don't show the body if there's an error
+ style = style.BorderLeftForeground(t.Error())
error = styles.BaseStyle().
+ Background(t.BackgroundSubtle()).
Foreground(t.Error()).
Render(m.(string))
- error = renderContentBlock(error, WithBorderColor(t.Error()), WithFullWidth(), WithMarginBottom(1))
+ error = renderContentBlock(
+ error,
+ WithFullWidth(),
+ WithBorderColor(t.Error()),
+ WithMarginBottom(1),
+ )
}
}
diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go
index 3e4909e6b..0a1515672 100644
--- a/packages/tui/internal/tui/tui.go
+++ b/packages/tui/internal/tui/tui.go
@@ -10,6 +10,7 @@ import (
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/app"
+ "github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/core"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/components/modal"
@@ -22,41 +23,7 @@ import (
"github.com/sst/opencode/pkg/client"
)
-type keyMap struct {
- Help key.Binding
- NewSession key.Binding
- SwitchSession key.Binding
- SwitchModel key.Binding
- SwitchTheme key.Binding
- Quit key.Binding
-}
-var keys = keyMap{
- Help: key.NewBinding(
- key.WithKeys("f1", "super+/", "super+h"),
- key.WithHelp("/help", "show help"),
- ),
- NewSession: key.NewBinding(
- key.WithKeys("f2", "super+n"),
- key.WithHelp("/new", "new session"),
- ),
- SwitchSession: key.NewBinding(
- key.WithKeys("f3", "super+s"),
- key.WithHelp("/sessions", "switch session"),
- ),
- SwitchModel: key.NewBinding(
- key.WithKeys("f4", "super+m"),
- key.WithHelp("/model", "switch model"),
- ),
- SwitchTheme: key.NewBinding(
- key.WithKeys("f5", "super+t"),
- key.WithHelp("/theme", "switch theme"),
- ),
- Quit: key.NewBinding(
- key.WithKeys("f10", "ctrl+c", "super+q"),
- key.WithHelp("/quit", "quit"),
- ),
-}
type appModel struct {
width, height int
@@ -67,6 +34,7 @@ type appModel struct {
status core.StatusComponent
app *app.App
modal layout.Modal
+ commands *commands.Registry
}
func (a appModel) Init() tea.Cmd {
@@ -131,12 +99,12 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
- isModalTrigger = key.Matches(msg, keys.NewSession) ||
- key.Matches(msg, keys.SwitchSession) ||
- key.Matches(msg, keys.SwitchModel) ||
- key.Matches(msg, keys.SwitchTheme) ||
- key.Matches(msg, keys.Help) ||
- key.Matches(msg, keys.Quit)
+ for _, cmdDef := range a.commands.Commands {
+ if key.Matches(msg, cmdDef.KeyBinding) {
+ isModalTrigger = true
+ break
+ }
+ }
}
if !isModalTrigger {
@@ -148,6 +116,38 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
+ case commands.ExecuteCommandMsg:
+ switch msg.Name {
+ case "quit":
+ quitDialog := dialog.NewQuitDialog()
+ a.modal = quitDialog
+ case "new":
+ a.app.Session = &client.SessionInfo{}
+ a.app.Messages = []client.MessageInfo{}
+ cmds = append(cmds, util.CmdHandler(state.SessionClearedMsg{}))
+ case "sessions":
+ sessionDialog := dialog.NewSessionDialog(a.app)
+ a.modal = sessionDialog
+ case "model":
+ modelDialog := dialog.NewModelDialog(a.app)
+ a.modal = modelDialog
+ case "theme":
+ themeDialog := dialog.NewThemeDialog()
+ a.modal = themeDialog
+ case "help":
+ var helpBindings []key.Binding
+ for _, cmd := range a.commands.Commands {
+ // Create a new binding for help display
+ helpBindings = append(helpBindings, key.NewBinding(
+ key.WithKeys(cmd.KeyBinding.Keys()...),
+ key.WithHelp("/"+cmd.Name, cmd.Description),
+ ))
+ }
+ helpDialog := dialog.NewHelpDialog(helpBindings...)
+ a.modal = helpDialog
+ }
+ return a, tea.Batch(cmds...)
+
case tea.BackgroundColorMsg:
styles.Terminal = &styles.TerminalInfo{
BackgroundIsDark: msg.IsDark(),
@@ -276,45 +276,15 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
- switch {
- case key.Matches(msg, keys.Help):
- helpDialog := dialog.NewHelpDialog(
- keys.Help,
- keys.NewSession,
- keys.SwitchSession,
- keys.SwitchModel,
- keys.SwitchTheme,
- keys.Quit,
- )
- a.modal = helpDialog
- return a, nil
-
- case key.Matches(msg, keys.NewSession):
- a.app.Session = &client.SessionInfo{}
- a.app.Messages = []client.MessageInfo{}
- return a, tea.Batch(
- util.CmdHandler(state.SessionClearedMsg{}),
- )
-
- case key.Matches(msg, keys.SwitchModel):
- modelDialog := dialog.NewModelDialog(a.app)
- a.modal = modelDialog
- return a, nil
-
- case key.Matches(msg, keys.SwitchSession):
- sessionDialog := dialog.NewSessionDialog(a.app)
- a.modal = sessionDialog
- return a, nil
-
- case key.Matches(msg, keys.SwitchTheme):
- themeDialog := dialog.NewThemeDialog()
- a.modal = themeDialog
- return a, nil
-
- case key.Matches(msg, keys.Quit):
- quitDialog := dialog.NewQuitDialog()
- a.modal = quitDialog
- return a, nil
+ // First, check for modal triggers from the command registry
+ if a.modal == nil {
+ for _, cmdDef := range a.commands.Commands {
+ if key.Matches(msg, cmdDef.KeyBinding) {
+ // If a key matches, send an ExecuteCommandMsg to self.
+ // This unifies keybinding and slash command handling.
+ return a, util.CmdHandler(commands.ExecuteCommandMsg{Name: cmdDef.Name})
+ }
+ }
}
}
@@ -361,6 +331,55 @@ func (a appModel) View() string {
return appView
}
+func newCommandRegistry() *commands.Registry {
+ return &commands.Registry{
+ Commands: map[string]commands.Command{
+ "help": {
+ Name: "help",
+ Description: "show help",
+ KeyBinding: key.NewBinding(
+ key.WithKeys("f1", "super+/", "super+h"),
+ ),
+ },
+ "new": {
+ Name: "new",
+ Description: "new session",
+ KeyBinding: key.NewBinding(
+ key.WithKeys("f2", "super+n"),
+ ),
+ },
+ "sessions": {
+ Name: "sessions",
+ Description: "switch session",
+ KeyBinding: key.NewBinding(
+ key.WithKeys("f3", "super+s"),
+ ),
+ },
+ "model": {
+ Name: "model",
+ Description: "switch model",
+ KeyBinding: key.NewBinding(
+ key.WithKeys("f4", "super+m"),
+ ),
+ },
+ "theme": {
+ Name: "theme",
+ Description: "switch theme",
+ KeyBinding: key.NewBinding(
+ key.WithKeys("f5", "super+t"),
+ ),
+ },
+ "quit": {
+ Name: "quit",
+ Description: "quit",
+ KeyBinding: key.NewBinding(
+ key.WithKeys("f10", "ctrl+c", "super+q"),
+ ),
+ },
+ },
+ }
+}
+
func NewModel(app *app.App) tea.Model {
startPage := page.ChatPage
model := &appModel{
@@ -368,6 +387,7 @@ func NewModel(app *app.App) tea.Model {
loadedPages: make(map[page.PageID]bool),
status: core.NewStatusCmp(app),
app: app,
+ commands: newCommandRegistry(),
pages: map[page.PageID]layout.ModelWithView{
page.ChatPage: page.NewChatPage(app),
},