feat(tui): configurable keybinds and mouse scroll

This commit is contained in:
adamdottv
2025-06-18 13:56:46 -05:00
parent d4157d9a96
commit bd46cf0f86
19 changed files with 1276 additions and 853 deletions

View File

@@ -3,10 +3,10 @@ package tui
import (
"context"
"log/slog"
"os"
"os/exec"
"github.com/charmbracelet/bubbles/v2/cursor"
"github.com/charmbracelet/bubbles/v2/key"
"github.com/charmbracelet/bubbles/v2/spinner"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
@@ -19,57 +19,34 @@ import (
"github.com/sst/opencode/internal/components/status"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
"github.com/sst/opencode/pkg/client"
)
type appModel struct {
width, height int
status status.StatusComponent
app *app.App
modal layout.Modal
editorContainer layout.Container
status status.StatusComponent
editor chat.EditorComponent
messagesContainer layout.Container
messages chat.MessagesComponent
editorContainer layout.Container
layout layout.FlexLayout
completionDialog dialog.CompletionDialog
completions 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"),
),
leaderBinding *key.Binding
isLeaderSequence bool
}
func (a appModel) Init() tea.Cmd {
t := theme.CurrentTheme()
var cmds []tea.Cmd
cmds = append(cmds, a.app.InitializeProvider())
cmds = append(cmds, tea.SetBackgroundColor(t.Background()))
cmds = append(cmds, tea.RequestBackgroundColor)
cmds = append(cmds, a.layout.Init())
cmds = append(cmds, a.completionDialog.Init())
cmds = append(cmds, a.app.InitializeProvider())
cmds = append(cmds, a.editor.Init())
cmds = append(cmds, a.messages.Init())
cmds = append(cmds, a.status.Init())
cmds = append(cmds, a.completions.Init())
// Check if we should show the init dialog
cmds = append(cmds, func() tea.Msg {
@@ -82,115 +59,124 @@ func (a appModel) Init() tea.Cmd {
func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
if a.modal != nil {
bypassModal := false
if _, ok := msg.(modal.CloseModalMsg); ok {
a.modal = nil
return a, nil
}
if msg, ok := msg.(tea.KeyMsg); ok {
switch msg := msg.(type) {
case tea.KeyPressMsg:
// 1. Handle active modal
if a.modal != nil {
switch msg.String() {
case "esc":
// Escape always closes current modal
case "esc", "ctrl+c":
a.modal = nil
return a, nil
case "ctrl+c":
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) {
bypassModal = true
break
}
}
}
// thanks i hate this
switch msg.(type) {
case tea.WindowSizeMsg:
bypassModal = true
case client.EventSessionUpdated:
bypassModal = true
case client.EventMessageUpdated:
bypassModal = true
case cursor.BlinkMsg:
bypassModal = true
case spinner.TickMsg:
bypassModal = true
}
if !bypassModal {
// Pass all other key presses to the modal
updatedModal, cmd := a.modal.Update(msg)
a.modal = updatedModal.(layout.Modal)
return a, 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":
return a, tea.Quit
case "new":
a.app.Session = &client.SessionInfo{}
a.app.Messages = []client.MessageInfo{}
cmds = append(cmds, util.CmdHandler(app.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 "share":
a.app.Client.PostSessionShareWithResponse(context.Background(), client.PostSessionShareJSONRequestBody{
SessionID: a.app.Session.Id,
})
case "init":
return a, a.app.InitializeProject(context.Background())
// case "compact":
// return a, a.app.CompactSession(context.Background())
case "help":
var helpBindings []key.Binding
for _, cmd := range a.app.Commands {
// Create a new binding for help display
helpBindings = append(helpBindings, key.NewBinding(
key.WithKeys(cmd.KeyBinding.Keys()...),
key.WithHelp("/"+cmd.Name, cmd.Description),
))
// 2. Check for commands that require leader
if a.isLeaderSequence {
matches := a.app.Commands.Matches(msg, a.isLeaderSequence)
// Reset leader state
a.isLeaderSequence = false
if len(matches) > 0 {
return a, util.CmdHandler(commands.ExecuteCommandsMsg(matches))
}
helpDialog := dialog.NewHelpDialog(helpBindings...)
a.modal = helpDialog
}
slog.Info("Execute command", "cmds", cmds)
return a, tea.Batch(cmds...)
// 3. Handle completions trigger
switch msg.String() {
case "/":
a.showCompletionDialog = true
}
if a.showCompletionDialog {
updated, cmd := a.editor.Update(msg)
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
currentInput := a.editor.Value()
provider := a.completionManager.GetProvider(currentInput)
a.completions.SetProvider(provider)
context, contextCmd := a.completions.Update(msg)
a.completions = context.(dialog.CompletionDialog)
cmds = append(cmds, contextCmd)
return a, tea.Batch(cmds...)
// Doesn't forward event if enter key is pressed
// if msg.String() == "enter" {
// return a, tea.Batch(cmds...)
// }
}
// 4. Maximize editor responsiveness for printable characters
if msg.Text != "" {
updated, cmd := a.editor.Update(msg)
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
return a, tea.Batch(cmds...)
}
// 5. Check for leader key activation
if a.leaderBinding != nil &&
!a.isLeaderSequence &&
key.Matches(msg, *a.leaderBinding) {
a.isLeaderSequence = true
return a, nil
}
// 6. Check again for commands that don't require leader
matches := a.app.Commands.Matches(msg, a.isLeaderSequence)
if len(matches) > 0 {
return a, util.CmdHandler(commands.ExecuteCommandsMsg(matches))
}
// 7. Fallback to editor. This shouldn't happen?
// All printable characters were already sent, and
// any other keypress that didn't match a command
// is likely a noop.
updatedEditor, cmd := a.editor.Update(msg)
a.editor = updatedEditor.(chat.EditorComponent)
return a, cmd
case tea.MouseWheelMsg:
if a.modal != nil {
return a, nil
}
updated, cmd := a.messages.Update(msg)
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case tea.BackgroundColorMsg:
styles.Terminal = &styles.TerminalInfo{
BackgroundIsDark: msg.IsDark(),
}
slog.Debug("Background color", "isDark", msg.IsDark())
case modal.CloseModalMsg:
a.modal = nil
return a, nil
case commands.ExecuteCommandMsg:
updated, cmd := a.executeCommand(commands.Command(msg))
return updated, cmd
case commands.ExecuteCommandsMsg:
for _, command := range msg {
updated, cmd := a.executeCommand(command)
if cmd != nil {
return updated, cmd
}
}
case app.SendMsg:
a.showCompletionDialog = false
cmd := a.app.SendChatMessage(context.Background(), msg.Text, msg.Attachments)
cmds = append(cmds, cmd)
case dialog.CompletionDialogCloseMsg:
a.showCompletionDialog = false
case client.EventSessionUpdated:
if msg.Properties.Info.Id == a.app.Session.Id {
a.app.Session = &msg.Properties.Info
}
case client.EventMessageUpdated:
if msg.Properties.Info.Metadata.SessionID == a.app.Session.Id {
exists := false
@@ -204,12 +190,9 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.app.Messages = append(a.app.Messages, msg.Properties.Info)
}
}
case tea.WindowSizeMsg:
msg.Height -= 2 // Make space for the status bar
a.width, a.height = msg.Width, msg.Height
// TODO: move away from global state
layout.Current = &layout.LayoutInfo{
Viewport: layout.Dimensions{
Width: a.width,
@@ -219,115 +202,19 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
Width: min(a.width, 80),
},
}
// Update status
s, cmd := a.status.Update(msg)
a.status = s.(status.StatusComponent)
if cmd != nil {
cmds = append(cmds, cmd)
}
// 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)
if cmd != nil {
cmds = append(cmds, cmd)
}
}
return a, tea.Batch(cmds...)
a.layout.SetSize(a.width, a.height)
case app.SessionSelectedMsg:
a.app.Session = msg
a.app.Messages, _ = a.app.ListMessages(context.Background(), msg.Id)
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()
a.app.SaveState()
case dialog.ThemeSelectedMsg:
a.app.Config.Theme = msg.ThemeName
a.app.SaveConfig()
// 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.(status.StatusComponent)
t := theme.CurrentTheme()
cmds = append(cmds, tea.SetBackgroundColor(t.Background()))
return a, tea.Batch(cmds...)
case tea.KeyMsg:
switch msg.String() {
// give the editor a chance to clear input
case "ctrl+c":
_, 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 {
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})
}
}
}
}
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...)
}
}
a.app.SaveState()
}
// update status bar
@@ -335,18 +222,30 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd)
a.status = s.(status.StatusComponent)
// update chat layout
u, cmd := a.layout.Update(msg)
a.layout = u.(layout.FlexLayout)
// update editor
u, cmd := a.editor.Update(msg)
a.editor = u.(chat.EditorComponent)
cmds = append(cmds, cmd)
return a, tea.Batch(cmds...)
}
func (a *appModel) sendMessage(text string, attachments []app.Attachment) tea.Cmd {
var cmds []tea.Cmd
cmd := a.app.SendChatMessage(context.Background(), text, attachments)
// update messages
u, cmd = a.messages.Update(msg)
a.messages = u.(chat.MessagesComponent)
cmds = append(cmds, cmd)
return tea.Batch(cmds...)
// update modal
if a.modal != nil {
u, cmd := a.modal.Update(msg)
a.modal = u.(layout.Modal)
cmds = append(cmds, cmd)
}
if a.showCompletionDialog {
u, cmd := a.completions.Update(msg)
a.completions = u.(dialog.CompletionDialog)
cmds = append(cmds, cmd)
}
return a, tea.Batch(cmds...)
}
func (a appModel) View() string {
@@ -356,8 +255,8 @@ func (a appModel) View() string {
editorWidth, _ := a.editorContainer.GetSize()
editorX, editorY := a.editorContainer.GetPosition()
a.completionDialog.SetWidth(editorWidth)
overlay := a.completionDialog.View()
a.completions.SetWidth(editorWidth)
overlay := a.completions.View()
layoutView = layout.PlaceOverlay(
editorX,
@@ -372,7 +271,6 @@ func (a appModel) View() string {
a.status.View(),
}
appView := lipgloss.JoinVertical(lipgloss.Top, components...)
if a.modal != nil {
appView = a.modal.Render(appView)
}
@@ -380,36 +278,219 @@ func (a appModel) View() string {
return appView
}
func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) {
cmds := []tea.Cmd{
util.CmdHandler(commands.CommandExecutedMsg(command)),
}
switch command.Name {
case commands.AppHelpCommand:
helpDialog := dialog.NewHelpDialog(a.app.Commands)
a.modal = helpDialog
case commands.EditorOpenCommand:
if a.app.IsBusy() {
// status.Warn("Agent is working, please wait...")
return a, nil
}
editor := os.Getenv("EDITOR")
if editor == "" {
// TODO: let the user know there's no EDITOR set
return a, nil
}
value := a.editor.Value()
updated, cmd := a.editor.Clear()
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
tmpfile, err := os.CreateTemp("", "msg_*.md")
tmpfile.WriteString(value)
if err != nil {
slog.Error("Failed to create temp file", "error", err)
return a, nil
}
tmpfile.Close()
c := exec.Command(editor, tmpfile.Name()) //nolint:gosec
c.Stdin = os.Stdin
c.Stdout = os.Stdout
c.Stderr = os.Stderr
cmd = tea.ExecProcess(c, func(err error) tea.Msg {
if err != nil {
slog.Error("Failed to open editor", "error", err)
return nil
}
content, err := os.ReadFile(tmpfile.Name())
if err != nil {
slog.Error("Failed to read file", "error", err)
return nil
}
if len(content) == 0 {
slog.Warn("Message is empty")
return nil
}
os.Remove(tmpfile.Name())
// attachments := m.attachments
// m.attachments = nil
return app.SendMsg{
Text: string(content),
Attachments: []app.Attachment{}, // attachments,
}
})
cmds = append(cmds, cmd)
case commands.SessionNewCommand:
if a.app.Session.Id == "" {
return a, nil
}
a.app.Session = &client.SessionInfo{}
a.app.Messages = []client.MessageInfo{}
cmds = append(cmds, util.CmdHandler(app.SessionClearedMsg{}))
case commands.SessionListCommand:
sessionDialog := dialog.NewSessionDialog(a.app)
a.modal = sessionDialog
case commands.SessionShareCommand:
if a.app.Session.Id == "" {
return a, nil
}
a.app.Client.PostSessionShareWithResponse(
context.Background(),
client.PostSessionShareJSONRequestBody{
SessionID: a.app.Session.Id,
},
)
case commands.SessionInterruptCommand:
if a.app.Session.Id == "" {
return a, nil
}
a.app.Cancel(context.Background(), a.app.Session.Id)
return a, nil
case commands.SessionCompactCommand:
if a.app.Session.Id == "" {
return a, nil
}
// TODO: block until compaction is complete
a.app.CompactSession(context.Background())
case commands.ToolDetailsCommand:
cmds = append(cmds, util.CmdHandler(chat.ToggleToolDetailsMsg{}))
case commands.ModelListCommand:
modelDialog := dialog.NewModelDialog(a.app)
a.modal = modelDialog
case commands.ThemeListCommand:
themeDialog := dialog.NewThemeDialog()
a.modal = themeDialog
case commands.ProjectInitCommand:
cmds = append(cmds, a.app.InitializeProject(context.Background()))
case commands.InputClearCommand:
if a.editor.Value() == "" {
return a, nil
}
updated, cmd := a.editor.Clear()
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
case commands.InputPasteCommand:
updated, cmd := a.editor.Paste()
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
case commands.InputSubmitCommand:
updated, cmd := a.editor.Submit()
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
case commands.InputNewlineCommand:
updated, cmd := a.editor.Newline()
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
case commands.HistoryPreviousCommand:
if a.showCompletionDialog {
return a, nil
}
updated, cmd := a.editor.Previous()
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
case commands.HistoryNextCommand:
if a.showCompletionDialog {
return a, nil
}
updated, cmd := a.editor.Next()
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
case commands.MessagesFirstCommand:
updated, cmd := a.messages.First()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case commands.MessagesLastCommand:
updated, cmd := a.messages.Last()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case commands.MessagesPageUpCommand:
if a.showCompletionDialog {
return a, nil
}
updated, cmd := a.messages.PageUp()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case commands.MessagesPageDownCommand:
if a.showCompletionDialog {
return a, nil
}
updated, cmd := a.messages.PageDown()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case commands.MessagesHalfPageUpCommand:
if a.showCompletionDialog {
return a, nil
}
updated, cmd := a.messages.HalfPageUp()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case commands.MessagesHalfPageDownCommand:
if a.showCompletionDialog {
return a, nil
}
updated, cmd := a.messages.HalfPageDown()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case commands.AppExitCommand:
return a, tea.Quit
}
return a, tea.Batch(cmds...)
}
func NewModel(app *app.App) tea.Model {
completionManager := completions.NewCompletionManager(app)
initialProvider := completionManager.GetProvider("")
completionDialog := dialog.NewCompletionDialogComponent(initialProvider)
messagesContainer := layout.NewContainer(
chat.NewMessagesComponent(app),
)
messages := chat.NewMessagesComponent(app)
editor := chat.NewEditorComponent(app)
completions := dialog.NewCompletionDialogComponent(initialProvider)
editorContainer := layout.NewContainer(
editor,
layout.WithMaxWidth(layout.Current.Container.Width),
layout.WithAlignCenter(),
)
messagesContainer := layout.NewContainer(messages)
var leaderBinding *key.Binding
if leader, ok := app.Config.Keybinds["leader"]; ok {
binding := key.NewBinding(key.WithKeys(leader))
leaderBinding = &binding
}
model := &appModel{
status: status.NewStatusCmp(app),
app: app,
editorContainer: editorContainer,
editor: editor,
messagesContainer: messagesContainer,
completionDialog: completionDialog,
messages: messages,
completions: completions,
completionManager: completionManager,
leaderBinding: leaderBinding,
isLeaderSequence: false,
showCompletionDialog: false,
editorContainer: editorContainer,
layout: layout.NewFlexLayout(
layout.WithPanes(messagesContainer, editorContainer),
[]tea.ViewModel{messagesContainer, editorContainer},
layout.WithDirection(layout.FlexDirectionVertical),
layout.WithPaneSizes(
layout.FlexPaneSizeGrow,
layout.FlexPaneSizeFixed(6),
layout.WithSizes(
layout.FlexChildSizeGrow,
layout.FlexChildSizeFixed(6),
),
),
}