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
No known key found for this signature in database
GPG key ID: 9CB48779AF150E75
19 changed files with 1276 additions and 853 deletions

2
.gitignore vendored
View file

@ -5,5 +5,3 @@ node_modules
.env
.idea
.vscode
app.log
gopls.log

View file

@ -71,8 +71,49 @@ theme = "opencode"
provider = "anthropic"
model = "claude-sonnet-4-20250514"
autoupdate = true
keybinds.leader = "ctrl+x"
keybinds.session_new = "<leader>n"
keybinds.editor_open = "<leader>e"
```
#### Keybinds
You can configure the keybinds in the global config file. (Note: values listed below are the defaults.)
```toml
# ~/.config/opencode/config
keybinds.leader = "ctrl+x"
keybinds.help = "<leader>h"
keybinds.editor_open = "<leader>e"
keybinds.session_new = "<leader>n"
keybinds.session_list = "<leader>l"
keybinds.session_share = "<leader>s"
keybinds.session_interrupt = "esc"
keybinds.session_compact = "<leader>c"
keybinds.tool_details = "<leader>d"
keybinds.model_list = "<leader>m"
keybinds.theme_list = "<leader>t"
keybinds.project_init = "<leader>i"
keybinds.input_clear = "ctrl+c"
keybinds.input_paste = "ctrl+v"
keybinds.input_submit = "enter"
keybinds.input_newline = "shift+enter"
keybinds.history_previous = "up"
keybinds.history_next = "down"
keybinds.messages_page_up = "pgup"
keybinds.messages_page_down = "pgdown"
keybinds.messages_half_page_up = "ctrl+alt+u"
keybinds.messages_half_page_down = "ctrl+alt+d"
keybinds.messages_previous = "ctrl+alt+k"
keybinds.messages_next = "ctrl+alt+j"
keybinds.messages_first = "ctrl+g"
keybinds.messages_last = "ctrl+alt+g"
keybinds.app_exit = "ctrl+c,<leader>q"
```
#### Models.dev
You can also extend the models.dev database with your own providers by mirroring the structure found [here](https://github.com/sst/models.dev/tree/dev/providers/anthropic)
Start with a `provider.toml` file in `~/.config/opencode/providers`
@ -171,8 +212,7 @@ To run.
```bash
$ bun install
$ cd packages/opencode
$ bun run src/index.ts
$ bun run packages/opencode/src/index.ts
```
### FAQ

View file

@ -23,10 +23,28 @@ func main() {
}
url := os.Getenv("OPENCODE_SERVER")
appInfoStr := os.Getenv("OPENCODE_APP_INFO")
var appInfo client.AppInfo
json.Unmarshal([]byte(appInfoStr), &appInfo)
logfile := filepath.Join(appInfo.Path.Data, "log", "tui.log")
if _, err := os.Stat(filepath.Dir(logfile)); os.IsNotExist(err) {
err := os.MkdirAll(filepath.Dir(logfile), 0755)
if err != nil {
slog.Error("Failed to create log directory", "error", err)
os.Exit(1)
}
}
file, err := os.Create(logfile)
if err != nil {
slog.Error("Failed to create log file", "error", err)
os.Exit(1)
}
defer file.Close()
logger := slog.New(slog.NewTextHandler(file, &slog.HandlerOptions{Level: slog.LevelDebug}))
slog.SetDefault(logger)
httpClient, err := client.NewClientWithResponses(url)
if err != nil {
slog.Error("Failed to create client", "error", err)
@ -46,7 +64,7 @@ func main() {
tui.NewModel(app_),
tea.WithAltScreen(),
tea.WithKeyboardEnhancements(),
// tea.WithMouseCellMotion(),
tea.WithMouseCellMotion(),
)
eventClient, err := client.NewClient(url)
@ -67,35 +85,10 @@ func main() {
}
}()
go func() {
paths, err := httpClient.PostPathGetWithResponse(context.Background())
if err != nil {
panic(err)
}
logfile := filepath.Join(paths.JSON200.Data, "log", "tui.log")
if _, err := os.Stat(filepath.Dir(logfile)); os.IsNotExist(err) {
err := os.MkdirAll(filepath.Dir(logfile), 0755)
if err != nil {
slog.Error("Failed to create log directory", "error", err)
os.Exit(1)
}
}
file, err := os.Create(logfile)
if err != nil {
slog.Error("Failed to create log file", "error", err)
os.Exit(1)
}
defer file.Close()
logger := slog.New(slog.NewTextHandler(file, &slog.HandlerOptions{Level: slog.LevelDebug}))
slog.SetDefault(logger)
}()
// Run the TUI
result, err := program.Run()
if err != nil {
slog.Error("TUI error", "error", err)
// return fmt.Errorf("TUI error: %v", err)
}
slog.Info("TUI exited", "result", result)

View file

@ -19,16 +19,16 @@ import (
var RootPath string
type App struct {
Info client.AppInfo
Version string
ConfigPath string
Config *config.Config
Client *client.ClientWithResponses
Provider *client.ProviderInfo
Model *client.ModelInfo
Session *client.SessionInfo
Messages []client.MessageInfo
Commands commands.Registry
Info client.AppInfo
Version string
StatePath string
Config *config.Config
Client *client.ClientWithResponses
Provider *client.ProviderInfo
Model *client.ModelInfo
Session *client.SessionInfo
Messages []client.MessageInfo
Commands commands.CommandRegistry
}
type SessionSelectedMsg = *client.SessionInfo
@ -38,6 +38,10 @@ type ModelSelectedMsg struct {
}
type SessionClearedMsg struct{}
type CompactSessionMsg struct{}
type SendMsg struct {
Text string
Attachments []Attachment
}
func New(
ctx context.Context,
@ -51,19 +55,33 @@ func New(
appConfig, err := config.LoadConfig(appConfigPath)
if err != nil {
appConfig = config.NewConfig()
config.SaveConfig(appConfigPath, appConfig)
}
theme.SetTheme(appConfig.Theme)
if len(appConfig.Keybinds) == 0 {
appConfig.Keybinds = make(map[string]string)
appConfig.Keybinds["leader"] = "ctrl+x"
}
appStatePath := filepath.Join(appInfo.Path.State, "tui")
appState, err := config.LoadState(appStatePath)
if err != nil {
appState = config.NewState()
config.SaveState(appStatePath, appState)
}
mergedConfig := config.MergeState(appState, appConfig)
theme.SetTheme(mergedConfig.Theme)
slog.Debug("Loaded config", "config", mergedConfig)
app := &App{
Info: appInfo,
Version: version,
ConfigPath: appConfigPath,
Config: appConfig,
Client: httpClient,
Session: &client.SessionInfo{},
Messages: []client.MessageInfo{},
Commands: commands.NewCommandRegistry(),
Info: appInfo,
Version: version,
StatePath: appStatePath,
Config: mergedConfig,
Client: httpClient,
Session: &client.SessionInfo{},
Messages: []client.MessageInfo{},
Commands: commands.LoadFromConfig(mergedConfig),
}
return app, nil
@ -160,8 +178,12 @@ func (a *App) IsBusy() bool {
return lastMessage.Metadata.Time.Completed == nil
}
func (a *App) SaveConfig() {
config.SaveConfig(a.ConfigPath, a.Config)
func (a *App) SaveState() {
state := config.ConfigToState(a.Config)
err := config.SaveState(a.StatePath, state)
if err != nil {
slog.Error("Failed to save state", "error", err)
}
}
func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
@ -348,3 +370,7 @@ func (a *App) ListProviders(ctx context.Context) ([]client.ProviderInfo, error)
providers := *resp.JSON200
return providers.Providers, nil
}
// func (a *App) loadCustomKeybinds() {
//
// }

View file

@ -1,91 +1,273 @@
package commands
import (
"github.com/charmbracelet/bubbles/v2/key"
"slices"
"strings"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode/internal/config"
)
// Command represents a user-triggerable action.
type ExecuteCommandMsg Command
type ExecuteCommandsMsg []Command
type CommandExecutedMsg Command
type Keybinding struct {
RequiresLeader bool
Key string
}
func (k Keybinding) Matches(msg tea.KeyPressMsg, leader bool) bool {
key := k.Key
key = strings.TrimSpace(key)
return key == msg.String() && (k.RequiresLeader == leader)
}
type CommandName string
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.
Name CommandName
Description string
// KeyBinding is the keyboard shortcut to trigger this command.
KeyBinding key.Binding
Keybindings []Keybinding
Trigger string
}
// Registry holds all the available commands.
type Registry map[string]Command
// ExecuteCommandMsg is a message sent when a command should be executed.
type ExecuteCommandMsg struct {
Name string
func (c Command) Keys() []string {
var keys []string
for _, k := range c.Keybindings {
keys = append(keys, k.Key)
}
return keys
}
func NewCommandRegistry() Registry {
return Registry{
"help": {
Name: "help",
type CommandRegistry map[CommandName]Command
func (r CommandRegistry) Matches(msg tea.KeyPressMsg, leader bool) []Command {
var matched []Command
for _, command := range r {
if command.Matches(msg, leader) {
matched = append(matched, command)
}
}
slices.SortFunc(matched, func(a, b Command) int {
if a.Name == AppExitCommand {
return 1
}
if b.Name == AppExitCommand {
return -1
}
return strings.Compare(string(a.Name), string(b.Name))
})
return matched
}
const (
AppHelpCommand CommandName = "app_help"
EditorOpenCommand CommandName = "editor_open"
SessionNewCommand CommandName = "session_new"
SessionListCommand CommandName = "session_list"
SessionShareCommand CommandName = "session_share"
SessionInterruptCommand CommandName = "session_interrupt"
SessionCompactCommand CommandName = "session_compact"
ToolDetailsCommand CommandName = "tool_details"
ModelListCommand CommandName = "model_list"
ThemeListCommand CommandName = "theme_list"
ProjectInitCommand CommandName = "project_init"
InputClearCommand CommandName = "input_clear"
InputPasteCommand CommandName = "input_paste"
InputSubmitCommand CommandName = "input_submit"
InputNewlineCommand CommandName = "input_newline"
HistoryPreviousCommand CommandName = "history_previous"
HistoryNextCommand CommandName = "history_next"
MessagesPageUpCommand CommandName = "messages_page_up"
MessagesPageDownCommand CommandName = "messages_page_down"
MessagesHalfPageUpCommand CommandName = "messages_half_page_up"
MessagesHalfPageDownCommand CommandName = "messages_half_page_down"
MessagesPreviousCommand CommandName = "messages_previous"
MessagesNextCommand CommandName = "messages_next"
MessagesFirstCommand CommandName = "messages_first"
MessagesLastCommand CommandName = "messages_last"
AppExitCommand CommandName = "app_exit"
)
func (k Command) Matches(msg tea.KeyPressMsg, leader bool) bool {
for _, binding := range k.Keybindings {
if binding.Matches(msg, leader) {
return true
}
}
return false
}
func (k Command) FromConfig(config *config.Config) Command {
if keybind, ok := config.Keybinds[string(k.Name)]; ok {
k.Keybindings = parseBindings(keybind)
}
return k
}
func parseBindings(bindings ...string) []Keybinding {
var parsedBindings []Keybinding
for _, binding := range bindings {
for p := range strings.SplitSeq(binding, ",") {
requireLeader := strings.HasPrefix(p, "<leader>")
keybinding := strings.ReplaceAll(p, "<leader>", "")
keybinding = strings.TrimSpace(keybinding)
parsedBindings = append(parsedBindings, Keybinding{
RequiresLeader: requireLeader,
Key: keybinding,
})
}
}
return parsedBindings
}
func LoadFromConfig(config *config.Config) CommandRegistry {
defaults := []Command{
{
Name: AppHelpCommand,
Description: "show help",
KeyBinding: key.NewBinding(
key.WithKeys("f1", "super+/", "super+h"),
),
Keybindings: parseBindings("<leader>h"),
Trigger: "help",
},
"new": {
Name: "new",
{
Name: EditorOpenCommand,
Description: "open editor",
Keybindings: parseBindings("<leader>e"),
Trigger: "editor",
},
{
Name: SessionNewCommand,
Description: "new session",
KeyBinding: key.NewBinding(
key.WithKeys("f2", "super+n"),
),
Keybindings: parseBindings("<leader>n"),
Trigger: "new",
},
"sessions": {
Name: "sessions",
Description: "switch session",
KeyBinding: key.NewBinding(
key.WithKeys("f3", "super+s"),
),
{
Name: SessionListCommand,
Description: "list sessions",
Keybindings: parseBindings("<leader>l"),
Trigger: "sessions",
},
"model": {
Name: "model",
Description: "switch model",
KeyBinding: key.NewBinding(
key.WithKeys("f4", "super+m"),
),
{
Name: SessionShareCommand,
Description: "share session",
Keybindings: parseBindings("<leader>s"),
Trigger: "share",
},
"theme": {
Name: "theme",
Description: "switch theme",
KeyBinding: key.NewBinding(
key.WithKeys("f5", "super+t"),
),
{
Name: SessionInterruptCommand,
Description: "interrupt session",
Keybindings: parseBindings("esc"),
},
"share": {
Name: "share",
Description: "create shareable link",
KeyBinding: key.NewBinding(
key.WithKeys("f6"),
),
{
Name: SessionCompactCommand,
Description: "compact the session",
Keybindings: parseBindings("<leader>c"),
Trigger: "compact",
},
"init": {
Name: "init",
{
Name: ToolDetailsCommand,
Description: "toggle tool details",
Keybindings: parseBindings("<leader>d"),
Trigger: "details",
},
{
Name: ModelListCommand,
Description: "list models",
Keybindings: parseBindings("<leader>m"),
Trigger: "models",
},
{
Name: ThemeListCommand,
Description: "list themes",
Keybindings: parseBindings("<leader>t"),
Trigger: "themes",
},
{
Name: ProjectInitCommand,
Description: "create or update AGENTS.md",
KeyBinding: key.NewBinding(
key.WithKeys("f7"),
),
Keybindings: parseBindings("<leader>i"),
Trigger: "init",
},
// "compact": {
// Name: "compact",
// Description: "compact the session",
// KeyBinding: key.NewBinding(
// key.WithKeys("f8"),
// ),
// },
"quit": {
Name: "quit",
Description: "quit",
KeyBinding: key.NewBinding(
key.WithKeys("f10", "ctrl+c", "super+q"),
),
{
Name: InputClearCommand,
Description: "clear input",
Keybindings: parseBindings("ctrl+c"),
},
{
Name: InputPasteCommand,
Description: "paste content",
Keybindings: parseBindings("ctrl+v"),
},
{
Name: InputSubmitCommand,
Description: "submit message",
Keybindings: parseBindings("enter"),
},
{
Name: InputNewlineCommand,
Description: "insert newline",
Keybindings: parseBindings("shift+enter"),
},
{
Name: HistoryPreviousCommand,
Description: "previous prompt",
Keybindings: parseBindings("up"),
},
{
Name: HistoryNextCommand,
Description: "next prompt",
Keybindings: parseBindings("down"),
},
{
Name: MessagesPageUpCommand,
Description: "page up",
Keybindings: parseBindings("pgup"),
},
{
Name: MessagesPageDownCommand,
Description: "page down",
Keybindings: parseBindings("pgdown"),
},
{
Name: MessagesHalfPageUpCommand,
Description: "half page up",
Keybindings: parseBindings("ctrl+alt+u"),
},
{
Name: MessagesHalfPageDownCommand,
Description: "half page down",
Keybindings: parseBindings("ctrl+alt+d"),
},
{
Name: MessagesPreviousCommand,
Description: "previous message",
Keybindings: parseBindings("ctrl+alt+k"),
},
{
Name: MessagesNextCommand,
Description: "next message",
Keybindings: parseBindings("ctrl+alt+j"),
},
{
Name: MessagesFirstCommand,
Description: "first message",
Keybindings: parseBindings("ctrl+g"),
},
{
Name: MessagesLastCommand,
Description: "last message",
Keybindings: parseBindings("ctrl+alt+g"),
},
{
Name: AppExitCommand,
Description: "exit the app",
Keybindings: parseBindings("ctrl+c", "<leader>q"),
Trigger: "exit",
},
}
registry := make(CommandRegistry)
for _, command := range defaults {
registry[command.Name] = command.FromConfig(config)
}
return registry
}

View file

@ -38,8 +38,8 @@ func (c *CommandCompletionProvider) GetEmptyMessage() string {
func getCommandCompletionItem(cmd commands.Command, space int) dialog.CompletionItemI {
t := theme.CurrentTheme()
spacer := strings.Repeat(" ", space)
title := " /" + cmd.Name + lipgloss.NewStyle().Foreground(t.TextMuted()).Render(spacer+cmd.Description)
value := "/" + cmd.Name
title := " /" + cmd.Trigger + lipgloss.NewStyle().Foreground(t.TextMuted()).Render(spacer+cmd.Description)
value := string(cmd.Name)
return dialog.NewCompletionItem(dialog.CompletionItem{
Title: title,
Value: value,
@ -49,8 +49,8 @@ func getCommandCompletionItem(cmd commands.Command, space int) dialog.Completion
func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
space := 1
for _, cmd := range c.app.Commands {
if lipgloss.Width(cmd.Name) > space {
space = lipgloss.Width(cmd.Name)
if lipgloss.Width(cmd.Trigger) > space {
space = lipgloss.Width(cmd.Trigger)
}
}
space += 2
@ -59,7 +59,10 @@ func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.Comp
// If no query, return all commands
items := []dialog.CompletionItemI{}
for _, cmd := range c.app.Commands {
space := space - lipgloss.Width(cmd.Name)
if cmd.Trigger == "" {
continue
}
space := space - lipgloss.Width(cmd.Trigger)
items = append(items, getCommandCompletionItem(cmd, space))
}
return items, nil
@ -70,9 +73,12 @@ func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.Comp
commandMap := make(map[string]dialog.CompletionItemI)
for _, cmd := range c.app.Commands {
space := space - lipgloss.Width(cmd.Name)
commandNames = append(commandNames, cmd.Name)
commandMap[cmd.Name] = getCommandCompletionItem(cmd, space)
if cmd.Trigger == "" {
continue
}
space := space - lipgloss.Width(cmd.Trigger)
commandNames = append(commandNames, cmd.Trigger)
commandMap[cmd.Trigger] = getCommandCompletionItem(cmd, space)
}
// Find fuzzy matches
@ -88,6 +94,5 @@ func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.Comp
items = append(items, item)
}
}
return items, nil
}

View file

@ -1,22 +0,0 @@
package chat
import (
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
type SendMsg struct {
Text string
Attachments []app.Attachment
}
func repo(width int) string {
repo := "github.com/sst/opencode"
t := theme.CurrentTheme()
return styles.BaseStyle().
Foreground(t.TextMuted()).
Width(width).
Render(repo)
}

View file

@ -3,11 +3,8 @@ package chat
import (
"fmt"
"log/slog"
"os"
"os/exec"
"strings"
"github.com/charmbracelet/bubbles/v2/key"
"github.com/charmbracelet/bubbles/v2/spinner"
"github.com/charmbracelet/bubbles/v2/textarea"
tea "github.com/charmbracelet/bubbletea/v2"
@ -16,6 +13,7 @@ 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"
@ -24,78 +22,27 @@ import (
type EditorComponent interface {
tea.Model
tea.ViewModel
layout.Sizeable
Value() string
Submit() (tea.Model, tea.Cmd)
Clear() (tea.Model, tea.Cmd)
Paste() (tea.Model, tea.Cmd)
Newline() (tea.Model, tea.Cmd)
Previous() (tea.Model, tea.Cmd)
Next() (tea.Model, tea.Cmd)
}
type editorComponent struct {
width int
height int
app *app.App
width, height int
textarea textarea.Model
attachments []app.Attachment
deleteMode bool
history []string
historyIndex int
currentMessage string
spinner spinner.Model
}
type EditorKeyMaps struct {
Send key.Binding
OpenEditor key.Binding
Paste key.Binding
HistoryUp key.Binding
HistoryDown key.Binding
}
type DeleteAttachmentKeyMaps struct {
AttachmentDeleteMode key.Binding
Escape key.Binding
DeleteAllAttachments key.Binding
}
var editorMaps = EditorKeyMaps{
Send: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "send message"),
),
OpenEditor: key.NewBinding(
key.WithKeys("f12"),
key.WithHelp("f12", "open editor"),
),
Paste: key.NewBinding(
key.WithKeys("ctrl+v"),
key.WithHelp("ctrl+v", "paste content"),
),
HistoryUp: key.NewBinding(
key.WithKeys("up"),
key.WithHelp("up", "previous message"),
),
HistoryDown: key.NewBinding(
key.WithKeys("down"),
key.WithHelp("down", "next message"),
),
}
var DeleteKeyMaps = DeleteAttachmentKeyMaps{
AttachmentDeleteMode: key.NewBinding(
key.WithKeys("ctrl+r"),
key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
),
Escape: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "cancel delete mode"),
),
DeleteAllAttachments: key.NewBinding(
key.WithKeys("r"),
key.WithHelp("ctrl+r+r", "delete all attachments"),
),
}
const (
maxAttachments = 5
)
func (m *editorComponent) Init() tea.Cmd {
return tea.Batch(textarea.Blink, m.spinner.Tick, tea.EnableReportFocus)
}
@ -104,153 +51,38 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyPressMsg:
// Maximize editor responsiveness for printable characters
if msg.Text != "" {
m.textarea, cmd = m.textarea.Update(msg)
return m, cmd
}
// // TODO: ?
// if key.Matches(msg, messageKeys.PageUp) ||
// key.Matches(msg, messageKeys.PageDown) ||
// key.Matches(msg, messageKeys.HalfPageUp) ||
// key.Matches(msg, messageKeys.HalfPageDown) {
// return m, nil
// }
case dialog.ThemeSelectedMsg:
m.textarea = createTextArea(&m.textarea)
m.spinner = createSpinner()
return m, m.spinner.Tick
return m, tea.Batch(m.spinner.Tick, textarea.Blink)
case dialog.CompletionSelectedMsg:
if msg.IsCommand {
// Execute the command directly
commandName := strings.TrimPrefix(msg.CompletionValue, "/")
m.textarea.Reset()
return m, util.CmdHandler(commands.ExecuteCommandMsg{Name: commandName})
return m, util.CmdHandler(
commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)]),
)
} else {
// For files, replace the text in the editor
existingValue := m.textarea.Value()
modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
m.textarea.SetValue(modifiedValue)
return m, nil
}
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
if m.textarea.Value() != "" {
m.textarea.Reset()
return m, func() tea.Msg {
return nil
}
}
case "shift+enter":
value := m.textarea.Value()
m.textarea.SetValue(value + "\n")
return m, nil
}
if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
m.deleteMode = true
return m, nil
}
if key.Matches(msg, DeleteKeyMaps.DeleteAllAttachments) && m.deleteMode {
m.deleteMode = false
m.attachments = nil
return m, nil
}
// if m.deleteMode && len(msg.Runes) > 0 && unicode.IsDigit(msg.Runes[0]) {
// num := int(msg.Runes[0] - '0')
// m.deleteMode = false
// if num < 10 && len(m.attachments) > num {
// if num == 0 {
// m.attachments = m.attachments[num+1:]
// } else {
// m.attachments = slices.Delete(m.attachments, num, num+1)
// }
// return m, nil
// }
// }
if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) ||
key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) {
return m, nil
}
if key.Matches(msg, editorMaps.OpenEditor) {
if m.app.IsBusy() {
// status.Warn("Agent is working, please wait...")
return m, nil
}
value := m.textarea.Value()
m.textarea.Reset()
return m, m.openEditor(value)
}
if key.Matches(msg, DeleteKeyMaps.Escape) {
m.deleteMode = false
return m, nil
}
if key.Matches(msg, editorMaps.Paste) {
imageBytes, text, err := image.GetImageFromClipboard()
if err != nil {
slog.Error(err.Error())
return m, cmd
}
if len(imageBytes) != 0 {
attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments))
attachment := app.Attachment{FilePath: attachmentName, FileName: attachmentName, Content: imageBytes, MimeType: "image/png"}
m.attachments = append(m.attachments, attachment)
} else {
m.textarea.SetValue(m.textarea.Value() + text)
}
return m, cmd
}
// Handle history navigation with up/down arrow keys
// Only handle history navigation if the filepicker is not open and completion dialog is not open
if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryUp) {
// TODO: fix this
// && !m.app.IsFilepickerOpen() && !m.app.IsCompletionDialogOpen() {
// Get the current line number
currentLine := m.textarea.Line()
// Only navigate history if we're at the first line
if currentLine == 0 && len(m.history) > 0 {
// Save current message if we're just starting to navigate
if m.historyIndex == len(m.history) {
m.currentMessage = m.textarea.Value()
}
// Go to previous message in history
if m.historyIndex > 0 {
m.historyIndex--
m.textarea.SetValue(m.history[m.historyIndex])
}
return m, nil
}
}
if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryDown) {
// TODO: fix this
// && !m.app.IsFilepickerOpen() && !m.app.IsCompletionDialogOpen() {
// Get the current line number and total lines
currentLine := m.textarea.Line()
value := m.textarea.Value()
lines := strings.Split(value, "\n")
totalLines := len(lines)
// Only navigate history if we're at the last line
if currentLine == totalLines-1 {
if m.historyIndex < len(m.history)-1 {
// Go to next message in history
m.historyIndex++
m.textarea.SetValue(m.history[m.historyIndex])
} else if m.historyIndex == len(m.history)-1 {
// Return to the current message being composed
m.historyIndex = len(m.history)
m.textarea.SetValue(m.currentMessage)
}
return m, nil
}
}
// Handle Enter key
if m.textarea.Focused() && key.Matches(msg, editorMaps.Send) {
value := m.textarea.Value()
if len(value) > 0 && value[len(value)-1] == '\\' {
// If the last character is a backslash, remove it and add a newline
m.textarea.SetValue(value[:len(value)-1] + "\n")
return m, nil
} else {
// Otherwise, send the message
return m, m.send()
}
}
}
m.spinner, cmd = m.spinner.Update(msg)
@ -304,10 +136,13 @@ func (m *editorComponent) View() string {
info = styles.Padded().Background(t.Background()).Render(info)
content := strings.Join([]string{"", textarea, info}, "\n")
return content
}
func (m *editorComponent) GetSize() (width, height int) {
return m.width, m.height
}
func (m *editorComponent) SetSize(width, height int) tea.Cmd {
m.width = width
m.height = height
@ -316,54 +151,22 @@ func (m *editorComponent) SetSize(width, height int) tea.Cmd {
return nil
}
func (m *editorComponent) GetSize() (int, int) {
return m.width, m.height
func (m *editorComponent) Value() string {
return strings.TrimSpace(m.textarea.Value())
}
func (m *editorComponent) openEditor(value string) tea.Cmd {
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "nvim"
}
tmpfile, err := os.CreateTemp("", "msg_*.md")
tmpfile.WriteString(value)
if err != nil {
// status.Error(err.Error())
return nil
}
tmpfile.Close()
c := exec.Command(editor, tmpfile.Name()) //nolint:gosec
c.Stdin = os.Stdin
c.Stdout = os.Stdout
c.Stderr = os.Stderr
return tea.ExecProcess(c, func(err error) tea.Msg {
if err != nil {
// status.Error(err.Error())
return nil
}
content, err := os.ReadFile(tmpfile.Name())
if err != nil {
// status.Error(err.Error())
return nil
}
if len(content) == 0 {
// status.Warn("Message is empty")
return nil
}
os.Remove(tmpfile.Name())
attachments := m.attachments
m.attachments = nil
return SendMsg{
Text: string(content),
Attachments: attachments,
}
})
}
func (m *editorComponent) send() tea.Cmd {
value := strings.TrimSpace(m.textarea.Value())
func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
value := m.Value()
m.textarea.Reset()
if value == "" {
return m, nil
}
if len(value) > 0 && value[len(value)-1] == '\\' {
// If the last character is a backslash, remove it and add a newline
m.textarea.SetValue(value[:len(value)-1] + "\n")
return m, nil
}
attachments := m.attachments
// Save to history if not empty and not a duplicate of the last entry
@ -376,26 +179,84 @@ func (m *editorComponent) send() tea.Cmd {
}
m.attachments = nil
if value == "" {
return nil
}
// Check for slash command
// if strings.HasPrefix(value, "/") {
// commandName := strings.TrimPrefix(value, "/")
// if _, ok := m.app.Commands[commandName]; ok {
// return util.CmdHandler(commands.ExecuteCommandMsg{Name: commandName})
// }
// }
return tea.Batch(
util.CmdHandler(SendMsg{
return m, tea.Batch(
util.CmdHandler(app.SendMsg{
Text: value,
Attachments: attachments,
}),
)
}
func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
m.textarea.Reset()
return m, nil
}
func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
imageBytes, text, err := image.GetImageFromClipboard()
if err != nil {
slog.Error(err.Error())
return m, nil
}
if len(imageBytes) != 0 {
attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments))
attachment := app.Attachment{FilePath: attachmentName, FileName: attachmentName, Content: imageBytes, MimeType: "image/png"}
m.attachments = append(m.attachments, attachment)
} else {
m.textarea.SetValue(m.textarea.Value() + text)
}
return m, nil
}
func (m *editorComponent) Newline() (tea.Model, tea.Cmd) {
value := m.textarea.Value()
m.textarea.SetValue(value + "\n")
return m, nil
}
func (m *editorComponent) Previous() (tea.Model, tea.Cmd) {
currentLine := m.textarea.Line()
// Only navigate history if we're at the first line
if currentLine == 0 && len(m.history) > 0 {
// Save current message if we're just starting to navigate
if m.historyIndex == len(m.history) {
m.currentMessage = m.textarea.Value()
}
// Go to previous message in history
if m.historyIndex > 0 {
m.historyIndex--
m.textarea.SetValue(m.history[m.historyIndex])
}
return m, nil
}
return m, nil
}
func (m *editorComponent) Next() (tea.Model, tea.Cmd) {
currentLine := m.textarea.Line()
value := m.textarea.Value()
lines := strings.Split(value, "\n")
totalLines := len(lines)
// Only navigate history if we're at the last line
if currentLine == totalLines-1 {
if m.historyIndex < len(m.history)-1 {
// Go to next message in history
m.historyIndex++
m.textarea.SetValue(m.history[m.historyIndex])
} else if m.historyIndex == len(m.history)-1 {
// Return to the current message being composed
m.historyIndex = len(m.history)
m.textarea.SetValue(m.currentMessage)
}
return m, nil
}
return m, nil
}
func createTextArea(existing *textarea.Model) textarea.Model {
t := theme.CurrentTheme()
bgColor := t.BackgroundElement()
@ -439,10 +300,6 @@ func createSpinner() spinner.Model {
)
}
func (m *editorComponent) Value() string {
return m.textarea.Value()
}
func NewEditorComponent(app *app.App) EditorComponent {
s := createSpinner()
ta := createTextArea(nil)

View file

@ -250,7 +250,7 @@ func renderToolInvocation(
toolCall client.MessageToolInvocationToolCall,
result *string,
metadata client.MessageInfo_Metadata_Tool_AdditionalProperties,
showResult bool,
showDetails bool,
isLast bool,
) string {
ignoredTools := []string{"opencode_todoread"}
@ -262,7 +262,7 @@ func renderToolInvocation(
innerWidth := outerWidth - 6
paddingTop := 0
paddingBottom := 0
if showResult {
if showDetails {
paddingTop = 1
if result == nil || *result == "" {
paddingBottom = 1
@ -284,8 +284,21 @@ func renderToolInvocation(
BorderStyle(lipgloss.ThickBorder())
if toolCall.State == "partial-call" {
title := renderToolAction(toolCall.ToolName)
if !showDetails {
title = "∟ " + title
padding := calculatePadding()
style := lipgloss.NewStyle().Width(outerWidth - padding - 4).Background(t.BackgroundSubtle())
return renderContentBlock(style.Render(title),
WithAlign(lipgloss.Left),
WithBorderColor(t.Accent()),
WithPaddingTop(0),
WithPaddingBottom(1),
)
}
style = style.Foreground(t.TextMuted())
return style.Render(renderToolAction(toolCall.ToolName))
return style.Render(title)
}
toolArgs := ""
@ -370,7 +383,7 @@ func renderToolInvocation(
BorderRight(true).
Render(formattedDiff)
if showResult {
if showDetails {
style = style.Width(lipgloss.Width(formattedDiff))
title += "\n"
}
@ -443,7 +456,8 @@ func renderToolInvocation(
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
}
if !showResult {
if !showDetails {
title = "∟ " + title
padding := calculatePadding()
style := lipgloss.NewStyle().Width(outerWidth - padding - 4).Background(t.BackgroundSubtle())
paddingBottom := 0
@ -471,10 +485,10 @@ func renderToolInvocation(
content,
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
)
if showResult && body != "" && error == "" {
if showDetails && body != "" && error == "" {
content += "\n" + body
}
if showResult && error != "" {
if showDetails && error != "" {
content += "\n" + error
}
return content
@ -561,6 +575,8 @@ func renderToolAction(name string) string {
return "Reading file..."
case "opencode_write":
return "Preparing write..."
case "opencode_todowrite", "opencode_todoread":
return "Planning..."
case "opencode_patch":
return "Preparing patch..."
case "opencode_batch":

View file

@ -5,7 +5,6 @@ import (
"strings"
"time"
"github.com/charmbracelet/bubbles/v2/key"
"github.com/charmbracelet/bubbles/v2/spinner"
"github.com/charmbracelet/bubbles/v2/viewport"
tea "github.com/charmbracelet/bubbletea/v2"
@ -21,47 +20,29 @@ import (
type MessagesComponent interface {
tea.Model
tea.ViewModel
PageUp() (tea.Model, tea.Cmd)
PageDown() (tea.Model, tea.Cmd)
HalfPageUp() (tea.Model, tea.Cmd)
HalfPageDown() (tea.Model, tea.Cmd)
First() (tea.Model, tea.Cmd)
Last() (tea.Model, tea.Cmd)
// Previous() (tea.Model, tea.Cmd)
// Next() (tea.Model, tea.Cmd)
}
type messagesComponent struct {
app *app.App
width, height int
app *app.App
viewport viewport.Model
spinner spinner.Model
rendering bool
attachments viewport.Model
showToolResults bool
cache *MessageCache
rendering bool
showToolDetails bool
tail bool
}
type renderFinishedMsg struct{}
type ToggleToolMessagesMsg struct{}
type MessageKeys struct {
PageDown key.Binding
PageUp key.Binding
HalfPageUp key.Binding
HalfPageDown key.Binding
}
var messageKeys = MessageKeys{
PageDown: key.NewBinding(
key.WithKeys("pgdown"),
key.WithHelp("f/pgdn", "page down"),
),
PageUp: key.NewBinding(
key.WithKeys("pgup"),
key.WithHelp("b/pgup", "page up"),
),
HalfPageUp: key.NewBinding(
key.WithKeys("ctrl+u"),
key.WithHelp("ctrl+u", "½ page up"),
),
HalfPageDown: key.NewBinding(
key.WithKeys("ctrl+d", "ctrl+d"),
key.WithHelp("ctrl+d", "½ page down"),
),
}
type ToggleToolDetailsMsg struct{}
func (m *messagesComponent) Init() tea.Cmd {
return tea.Batch(m.viewport.Init(), m.spinner.Tick)
@ -69,8 +50,8 @@ func (m *messagesComponent) Init() tea.Cmd {
func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case SendMsg:
switch msg.(type) {
case app.SendMsg:
m.viewport.GotoBottom()
m.tail = true
return m, nil
@ -78,8 +59,8 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.cache.Clear()
m.renderView()
return m, nil
case ToggleToolMessagesMsg:
m.showToolResults = !m.showToolResults
case ToggleToolDetailsMsg:
m.showToolDetails = !m.showToolDetails
m.renderView()
return m, nil
case app.SessionSelectedMsg:
@ -91,33 +72,23 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.cache.Clear()
cmd := m.Reload()
return m, cmd
case tea.KeyMsg:
if key.Matches(msg, messageKeys.PageUp) ||
key.Matches(msg, messageKeys.PageDown) ||
key.Matches(msg, messageKeys.HalfPageUp) ||
key.Matches(msg, messageKeys.HalfPageDown) {
u, cmd := m.viewport.Update(msg)
m.viewport = u
m.tail = m.viewport.AtBottom()
cmds = append(cmds, cmd)
}
case renderFinishedMsg:
m.rendering = false
if m.tail {
m.viewport.GotoBottom()
}
case client.EventSessionUpdated:
m.renderView()
if m.tail {
m.viewport.GotoBottom()
}
case client.EventMessageUpdated:
case client.EventSessionUpdated, client.EventMessageUpdated:
m.renderView()
if m.tail {
m.viewport.GotoBottom()
}
}
viewport, cmd := m.viewport.Update(msg)
m.viewport = viewport
m.tail = m.viewport.AtBottom()
cmds = append(cmds, cmd)
spinner, cmd := m.spinner.Update(msg)
m.spinner = spinner
cmds = append(cmds, cmd)
@ -208,7 +179,7 @@ func (m *messagesComponent) renderView() {
if toolCall.State == "result" {
key := m.cache.GenerateKey(message.Id,
toolCall.ToolCallId,
m.showToolResults,
m.showToolDetails,
layout.Current.Viewport.Width,
)
content, cached = m.cache.Get(key)
@ -217,7 +188,7 @@ func (m *messagesComponent) renderView() {
toolCall,
result,
metadata,
m.showToolResults,
m.showToolDetails,
isLastToolInvocation,
)
m.cache.Set(key, content)
@ -228,12 +199,12 @@ func (m *messagesComponent) renderView() {
toolCall,
result,
metadata,
m.showToolResults,
m.showToolDetails,
isLastToolInvocation,
)
}
if previousBlockType != toolInvocationBlock && m.showToolResults {
if previousBlockType != toolInvocationBlock && m.showToolDetails {
blocks = append(blocks, "")
}
blocks = append(blocks, content)
@ -423,6 +394,38 @@ func (m *messagesComponent) Reload() tea.Cmd {
}
}
func (m *messagesComponent) PageUp() (tea.Model, tea.Cmd) {
m.viewport.ViewUp()
return m, nil
}
func (m *messagesComponent) PageDown() (tea.Model, tea.Cmd) {
m.viewport.ViewDown()
return m, nil
}
func (m *messagesComponent) HalfPageUp() (tea.Model, tea.Cmd) {
m.viewport.HalfViewUp()
return m, nil
}
func (m *messagesComponent) HalfPageDown() (tea.Model, tea.Cmd) {
m.viewport.HalfViewDown()
return m, nil
}
func (m *messagesComponent) First() (tea.Model, tea.Cmd) {
m.viewport.GotoTop()
m.tail = false
return m, nil
}
func (m *messagesComponent) Last() (tea.Model, tea.Cmd) {
m.viewport.GotoBottom()
m.tail = true
return m, nil
}
func NewMessagesComponent(app *app.App) MessagesComponent {
customSpinner := spinner.Spinner{
Frames: []string{" ", "┃", "┃"},
@ -432,17 +435,14 @@ func NewMessagesComponent(app *app.App) MessagesComponent {
vp := viewport.New()
attachments := viewport.New()
vp.KeyMap.PageUp = messageKeys.PageUp
vp.KeyMap.PageDown = messageKeys.PageDown
vp.KeyMap.HalfPageUp = messageKeys.HalfPageUp
vp.KeyMap.HalfPageDown = messageKeys.HalfPageDown
vp.KeyMap = viewport.KeyMap{}
return &messagesComponent{
app: app,
viewport: vp,
spinner: s,
attachments: attachments,
showToolResults: true,
showToolDetails: true,
cache: NewMessageCache(),
tail: true,
}

View file

@ -1,6 +1,8 @@
package dialog
import (
"log/slog"
"github.com/charmbracelet/bubbles/v2/key"
"github.com/charmbracelet/bubbles/v2/textarea"
tea "github.com/charmbracelet/bubbletea/v2"
@ -144,6 +146,7 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
c.list.SetItems(msg)
case tea.KeyMsg:
if c.pseudoSearchTextArea.Focused() {
slog.Info("CompletionDialog", "key", msg.String(), "focused", true)
if !key.Matches(msg, completionDialogKeys.Complete) {
var cmd tea.Cmd
c.pseudoSearchTextArea, cmd = c.pseudoSearchTextArea.Update(msg)
@ -159,10 +162,10 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
c.query = query
cmd = func() tea.Msg {
items, err := c.completionProvider.GetChildEntries(query)
slog.Info("CompletionDialog", "query", query, "items", len(items))
if err != nil {
// status.Error(err.Error())
slog.Error("Failed to get completion items", "error", err)
}
// c.list.SetItems(items)
return items
}
cmds = append(cmds, cmd)
@ -189,9 +192,11 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return c, tea.Batch(cmds...)
} else {
slog.Info("CompletionDialog", "key", msg.String(), "focused", false)
cmd := func() tea.Msg {
items, err := c.completionProvider.GetChildEntries("")
if err != nil {
slog.Error("Failed to get completion items", "error", err)
// status.Error(err.Error())
}
return items

View file

@ -3,9 +3,9 @@ package dialog
import (
"strings"
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/theme"
@ -15,28 +15,9 @@ type helpDialog struct {
width int
height int
modal *modal.Modal
bindings []key.Binding
commands commands.CommandRegistry
}
// func (i bindingItem) Render(selected bool, width int) string {
// t := theme.CurrentTheme()
// baseStyle := styles.BaseStyle().
// Width(width - 2).
// Background(t.BackgroundElement())
//
// if selected {
// baseStyle = baseStyle.
// Background(t.Primary()).
// Foreground(t.BackgroundElement()).
// Bold(true)
// } else {
// baseStyle = baseStyle.
// Foreground(t.Text())
// }
//
// return baseStyle.Padding(0, 1).Render(i.binding.Help().Desc)
// }
func (h *helpDialog) Init() tea.Cmd {
return nil
}
@ -63,19 +44,24 @@ func (h *helpDialog) View() string {
PaddingLeft(1).Background(t.BackgroundElement())
lines := []string{}
for _, b := range h.bindings {
content := keyStyle.Render(b.Help().Key)
content += descStyle.Render(" " + b.Help().Desc)
for i, key := range b.Keys() {
if i == 0 {
keyString := " (" + strings.ToUpper(key) + ")"
// space := max(h.width-lipgloss.Width(content)-lipgloss.Width(keyString), 0)
// spacer := strings.Repeat(" ", space)
// content += descStyle.Render(spacer)
content += descStyle.Render(keyString)
}
for _, b := range h.commands {
// Only interested in slash commands
if b.Trigger == "" {
continue
}
content := keyStyle.Render("/" + b.Trigger)
content += descStyle.Render(" " + b.Description)
// for i, key := range b.Keybindings {
// if i == 0 {
// keyString := " (" + key.Key + ")"
// space := max(h.width-lipgloss.Width(content)-lipgloss.Width(keyString), 0)
// spacer := strings.Repeat(" ", space)
// content += descStyle.Render(spacer)
// content += descStyle.Render(keyString)
// }
// }
lines = append(lines, contentStyle.Render(content))
}
@ -94,9 +80,9 @@ type HelpDialog interface {
layout.Modal
}
func NewHelpDialog(bindings ...key.Binding) HelpDialog {
func NewHelpDialog(commands commands.CommandRegistry) HelpDialog {
return &helpDialog{
bindings: bindings,
commands: commands,
modal: modal.New(),
}
}

View file

@ -9,23 +9,57 @@ import (
"github.com/BurntSushi/toml"
)
type Config struct {
type State struct {
Theme string `toml:"theme"`
Provider string `toml:"provider"`
Model string `toml:"model"`
}
// NewConfig creates a new Config instance with default values.
// This can be useful for initializing a new configuration file.
func NewConfig() *Config {
return &Config{
type Config struct {
Theme string `toml:"theme"`
Provider string `toml:"provider"`
Model string `toml:"model"`
Keybinds map[string]string `toml:"keybinds"`
}
func NewState() *State {
return &State{
Theme: "opencode",
}
}
// SaveConfig writes the provided Config struct to the specified TOML file.
func NewConfig() *Config {
keybinds := make(map[string]string)
keybinds["leader"] = "ctrl+x"
return &Config{
Keybinds: keybinds,
}
}
func ConfigToState(config *Config) *State {
return &State{
Theme: config.Theme,
Provider: config.Provider,
Model: config.Model,
}
}
func MergeState(state *State, config *Config) *Config {
if config.Theme == "" {
config.Theme = state.Theme
}
if config.Provider == "" {
config.Provider = state.Provider
}
if config.Model == "" {
config.Model = state.Model
}
return config
}
// SaveState writes the provided Config struct to the specified TOML file.
// It will create the file if it doesn't exist, or overwrite it if it does.
func SaveConfig(filePath string, config *Config) error {
func SaveState(filePath string, state *State) error {
file, err := os.Create(filePath)
if err != nil {
return fmt.Errorf("failed to create/open config file %s: %w", filePath, err)
@ -34,14 +68,14 @@ func SaveConfig(filePath string, config *Config) error {
writer := bufio.NewWriter(file)
encoder := toml.NewEncoder(writer)
if err := encoder.Encode(config); err != nil {
return fmt.Errorf("failed to encode config to TOML file %s: %w", filePath, err)
if err := encoder.Encode(state); err != nil {
return fmt.Errorf("failed to encode state to TOML file %s: %w", filePath, err)
}
if err := writer.Flush(); err != nil {
return fmt.Errorf("failed to flush writer for config file %s: %w", filePath, err)
return fmt.Errorf("failed to flush writer for state file %s: %w", filePath, err)
}
slog.Debug("Configuration saved to file", "file", filePath)
slog.Debug("State saved to file", "file", filePath)
return nil
}
@ -57,3 +91,16 @@ func LoadConfig(filePath string) (*Config, error) {
}
return &config, nil
}
// LoadState loads the state from the specified TOML file.
// It returns a pointer to the State struct and an error if any issues occur.
func LoadState(filePath string) (*State, error) {
var state State
if _, err := toml.DecodeFile(filePath, &state); err != nil {
if _, statErr := os.Stat(filePath); os.IsNotExist(statErr) {
return nil, fmt.Errorf("state file not found at %s: %w", filePath, statErr)
}
return nil, fmt.Errorf("failed to decode TOML from file %s: %w", filePath, err)
}
return &state, nil
}

View file

@ -11,9 +11,7 @@ type Container interface {
tea.ViewModel
Sizeable
Focusable
MaxWidth() int
Alignment() lipgloss.Position
GetPosition() (x, y int)
Alignable
}
type container struct {
@ -185,6 +183,11 @@ func (c *container) GetPosition() (x, y int) {
return c.x, c.y
}
func (c *container) SetPosition(x, y int) {
c.x = x
c.y = y
}
type ContainerOption func(*container)
func NewContainer(content tea.ViewModel, options ...ContainerOption) Container {

View file

@ -13,23 +13,22 @@ const (
FlexDirectionVertical
)
type FlexPaneSize struct {
type FlexChildSize struct {
Fixed bool
Size int
}
var FlexPaneSizeGrow = FlexPaneSize{Fixed: false}
var FlexChildSizeGrow = FlexChildSize{Fixed: false}
func FlexPaneSizeFixed(size int) FlexPaneSize {
return FlexPaneSize{Fixed: true, Size: size}
func FlexChildSizeFixed(size int) FlexChildSize {
return FlexChildSize{Fixed: true, Size: size}
}
type FlexLayout interface {
tea.Model
tea.ViewModel
Sizeable
SetPanes(panes []Container) tea.Cmd
SetPaneSizes(sizes []FlexPaneSize) tea.Cmd
SetChildren(panes []tea.ViewModel) tea.Cmd
SetSizes(sizes []FlexChildSize) tea.Cmd
SetDirection(direction FlexDirection) tea.Cmd
}
@ -37,94 +36,69 @@ type flexLayout struct {
width int
height int
direction FlexDirection
panes []Container
sizes []FlexPaneSize
children []tea.ViewModel
sizes []FlexChildSize
}
type FlexLayoutOption func(*flexLayout)
func (f *flexLayout) Init() tea.Cmd {
var cmds []tea.Cmd
for _, pane := range f.panes {
if pane != nil {
cmds = append(cmds, pane.Init())
}
}
return tea.Batch(cmds...)
}
func (f *flexLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
return f, f.SetSize(msg.Width, msg.Height)
}
for i, pane := range f.panes {
if pane != nil {
u, cmd := pane.Update(msg)
f.panes[i] = u.(Container)
if cmd != nil {
cmds = append(cmds, cmd)
}
}
}
return f, tea.Batch(cmds...)
}
func (f *flexLayout) View() string {
if len(f.panes) == 0 {
if len(f.children) == 0 {
return ""
}
t := theme.CurrentTheme()
views := make([]string, 0, len(f.panes))
for i, pane := range f.panes {
if pane == nil {
views := make([]string, 0, len(f.children))
for i, child := range f.children {
if child == nil {
continue
}
var paneWidth, paneHeight int
alignment := lipgloss.Center
if alignable, ok := child.(Alignable); ok {
alignment = alignable.Alignment()
}
var childWidth, childHeight int
if f.direction == FlexDirectionHorizontal {
paneWidth, paneHeight = f.calculatePaneSize(i)
childWidth, childHeight = f.calculateChildSize(i)
view := lipgloss.PlaceHorizontal(
paneWidth,
pane.Alignment(),
pane.View(),
childWidth,
alignment,
child.View(),
// TODO: make configurable WithBackgroundStyle
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
)
views = append(views, view)
} else {
paneWidth, paneHeight = f.calculatePaneSize(i)
childWidth, childHeight = f.calculateChildSize(i)
view := lipgloss.Place(
f.width,
paneHeight,
childHeight,
lipgloss.Center,
pane.Alignment(),
pane.View(),
alignment,
child.View(),
// TODO: make configurable WithBackgroundStyle
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
)
views = append(views, view)
}
}
if f.direction == FlexDirectionHorizontal {
return lipgloss.JoinHorizontal(lipgloss.Center, views...)
}
return lipgloss.JoinVertical(lipgloss.Center, views...)
}
func (f *flexLayout) calculatePaneSize(index int) (width, height int) {
if index >= len(f.panes) {
func (f *flexLayout) calculateChildSize(index int) (width, height int) {
if index >= len(f.children) {
return 0, 0
}
totalFixed := 0
flexCount := 0
for i, pane := range f.panes {
if pane == nil {
for i, child := range f.children {
if child == nil {
continue
}
if i < len(f.sizes) && f.sizes[i].Fixed {
@ -166,9 +140,13 @@ func (f *flexLayout) SetSize(width, height int) tea.Cmd {
var cmds []tea.Cmd
currentX, currentY := 0, 0
for i, pane := range f.panes {
if pane != nil {
paneWidth, paneHeight := f.calculatePaneSize(i)
for i, child := range f.children {
if child != nil {
paneWidth, paneHeight := f.calculateChildSize(i)
alignment := lipgloss.Center
if alignable, ok := child.(Alignable); ok {
alignment = alignable.Alignment()
}
// Calculate actual position based on alignment
actualX, actualY := currentX, currentY
@ -180,11 +158,13 @@ func (f *flexLayout) SetSize(width, height int) tea.Cmd {
} else {
// In vertical layout, horizontal alignment affects X position
contentWidth := paneWidth
if pane.MaxWidth() > 0 && contentWidth > pane.MaxWidth() {
contentWidth = pane.MaxWidth()
if alignable, ok := child.(Alignable); ok {
if alignable.MaxWidth() > 0 && contentWidth > alignable.MaxWidth() {
contentWidth = alignable.MaxWidth()
}
}
switch pane.Alignment() {
switch alignment {
case lipgloss.Center:
actualX = (f.width - contentWidth) / 2
case lipgloss.Right:
@ -194,14 +174,15 @@ func (f *flexLayout) SetSize(width, height int) tea.Cmd {
}
}
// Set position if the pane is a *container
if c, ok := pane.(*container); ok {
c.x = actualX
c.y = actualY
// Set position if the pane is Alignable
if c, ok := child.(Alignable); ok {
c.SetPosition(actualX, actualY)
}
cmd := pane.SetSize(paneWidth, paneHeight)
cmds = append(cmds, cmd)
if sizeable, ok := child.(Sizeable); ok {
cmd := sizeable.SetSize(paneWidth, paneHeight)
cmds = append(cmds, cmd)
}
// Update position for next pane
if f.direction == FlexDirectionHorizontal {
@ -218,15 +199,15 @@ func (f *flexLayout) GetSize() (int, int) {
return f.width, f.height
}
func (f *flexLayout) SetPanes(panes []Container) tea.Cmd {
f.panes = panes
func (f *flexLayout) SetChildren(children []tea.ViewModel) tea.Cmd {
f.children = children
if f.width > 0 && f.height > 0 {
return f.SetSize(f.width, f.height)
}
return nil
}
func (f *flexLayout) SetPaneSizes(sizes []FlexPaneSize) tea.Cmd {
func (f *flexLayout) SetSizes(sizes []FlexChildSize) tea.Cmd {
f.sizes = sizes
if f.width > 0 && f.height > 0 {
return f.SetSize(f.width, f.height)
@ -242,11 +223,11 @@ func (f *flexLayout) SetDirection(direction FlexDirection) tea.Cmd {
return nil
}
func NewFlexLayout(options ...FlexLayoutOption) FlexLayout {
func NewFlexLayout(children []tea.ViewModel, options ...FlexLayoutOption) FlexLayout {
layout := &flexLayout{
children: children,
direction: FlexDirectionHorizontal,
panes: []Container{},
sizes: []FlexPaneSize{},
sizes: []FlexChildSize{},
}
for _, option := range options {
option(layout)
@ -260,13 +241,13 @@ func WithDirection(direction FlexDirection) FlexLayoutOption {
}
}
func WithPanes(panes ...Container) FlexLayoutOption {
func WithChildren(children ...tea.ViewModel) FlexLayoutOption {
return func(f *flexLayout) {
f.panes = panes
f.children = children
}
}
func WithPaneSizes(sizes ...FlexPaneSize) FlexLayoutOption {
func WithSizes(sizes ...FlexChildSize) FlexLayoutOption {
return func(f *flexLayout) {
f.sizes = sizes
}

View file

@ -5,6 +5,7 @@ import (
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
)
var Current *LayoutInfo
@ -45,6 +46,13 @@ type Sizeable interface {
GetSize() (int, int)
}
type Alignable interface {
MaxWidth() int
Alignment() lipgloss.Position
SetPosition(x, y int)
GetPosition() (x, y int)
}
func KeyMapToSlice(t any) (bindings []key.Binding) {
typ := reflect.TypeOf(t)
if typ.Kind() != reflect.Struct {

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),
),
),
}

View file

@ -478,6 +478,25 @@
}
}
}
},
"/installation_info": {
"post": {
"responses": {
"200": {
"description": "Get installation info",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InstallationInfo"
}
}
}
}
},
"operationId": "postInstallation_info",
"parameters": [],
"description": "Get installation info"
}
}
},
"components": {
@ -504,6 +523,9 @@
},
{
"$ref": "#/components/schemas/Event.session.error"
},
{
"$ref": "#/components/schemas/Event.installation.updated"
}
],
"discriminator": {
@ -515,7 +537,8 @@
"message.updated": "#/components/schemas/Event.message.updated",
"message.part.updated": "#/components/schemas/Event.message.part.updated",
"session.updated": "#/components/schemas/Event.session.updated",
"session.error": "#/components/schemas/Event.session.error"
"session.error": "#/components/schemas/Event.session.error",
"installation.updated": "#/components/schemas/Event.installation.updated"
}
}
},
@ -1269,6 +1292,30 @@
"properties"
]
},
"Event.installation.updated": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "installation.updated"
},
"properties": {
"type": "object",
"properties": {
"version": {
"type": "string"
}
},
"required": [
"version"
]
}
},
"required": [
"type",
"properties"
]
},
"App.Info": {
"type": "object",
"properties": {
@ -1292,13 +1339,17 @@
},
"cwd": {
"type": "string"
},
"state": {
"type": "string"
}
},
"required": [
"config",
"data",
"root",
"cwd"
"cwd",
"state"
]
},
"time": {
@ -1344,6 +1395,9 @@
"id": {
"type": "string"
},
"npm": {
"type": "string"
},
"models": {
"type": "object",
"additionalProperties": {
@ -1424,6 +1478,21 @@
"limit",
"id"
]
},
"InstallationInfo": {
"type": "object",
"properties": {
"version": {
"type": "string"
},
"latest": {
"type": "string"
}
},
"required": [
"version",
"latest"
]
}
}
}

View file

@ -31,6 +31,7 @@ type AppInfo struct {
Cwd string `json:"cwd"`
Data string `json:"data"`
Root string `json:"root"`
State string `json:"state"`
} `json:"path"`
Time struct {
Initialized *float32 `json:"initialized,omitempty"`
@ -48,6 +49,14 @@ type Event struct {
union json.RawMessage
}
// EventInstallationUpdated defines model for Event.installation.updated.
type EventInstallationUpdated struct {
Properties struct {
Version string `json:"version"`
} `json:"properties"`
Type string `json:"type"`
}
// EventLspClientDiagnostics defines model for Event.lsp.client.diagnostics.
type EventLspClientDiagnostics struct {
Properties struct {
@ -111,6 +120,12 @@ type EventStorageWrite struct {
Type string `json:"type"`
}
// InstallationInfo defines model for InstallationInfo.
type InstallationInfo struct {
Latest string `json:"latest"`
Version string `json:"version"`
}
// MessageInfo defines model for Message.Info.
type MessageInfo struct {
Id string `json:"id"`
@ -269,6 +284,7 @@ type ProviderInfo struct {
Id string `json:"id"`
Models map[string]ModelInfo `json:"models"`
Name string `json:"name"`
Npm *string `json:"npm,omitempty"`
}
// ProviderAuthError defines model for ProviderAuthError.
@ -652,6 +668,34 @@ func (t *Event) MergeEventSessionError(v EventSessionError) error {
return err
}
// AsEventInstallationUpdated returns the union data inside the Event as a EventInstallationUpdated
func (t Event) AsEventInstallationUpdated() (EventInstallationUpdated, error) {
var body EventInstallationUpdated
err := json.Unmarshal(t.union, &body)
return body, err
}
// FromEventInstallationUpdated overwrites any union data inside the Event as the provided EventInstallationUpdated
func (t *Event) FromEventInstallationUpdated(v EventInstallationUpdated) error {
v.Type = "installation.updated"
b, err := json.Marshal(v)
t.union = b
return err
}
// MergeEventInstallationUpdated performs a merge with any union data inside the Event, using the provided EventInstallationUpdated
func (t *Event) MergeEventInstallationUpdated(v EventInstallationUpdated) error {
v.Type = "installation.updated"
b, err := json.Marshal(v)
if err != nil {
return err
}
merged, err := runtime.JSONMerge(t.union, b)
t.union = merged
return err
}
func (t Event) Discriminator() (string, error) {
var discriminator struct {
Discriminator string `json:"type"`
@ -666,6 +710,8 @@ func (t Event) ValueByDiscriminator() (interface{}, error) {
return nil, err
}
switch discriminator {
case "installation.updated":
return t.AsEventInstallationUpdated()
case "lsp.client.diagnostics":
return t.AsEventLspClientDiagnostics()
case "message.part.updated":
@ -1288,6 +1334,9 @@ type ClientInterface interface {
PostFileSearch(ctx context.Context, body PostFileSearchJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
// PostInstallationInfo request
PostInstallationInfo(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error)
// PostPathGet request
PostPathGet(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error)
@ -1391,6 +1440,18 @@ func (c *Client) PostFileSearch(ctx context.Context, body PostFileSearchJSONRequ
return c.Client.Do(req)
}
func (c *Client) PostInstallationInfo(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewPostInstallationInfoRequest(c.Server)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
return c.Client.Do(req)
}
func (c *Client) PostPathGet(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewPostPathGetRequest(c.Server)
if err != nil {
@ -1704,6 +1765,33 @@ func NewPostFileSearchRequestWithBody(server string, contentType string, body io
return req, nil
}
// NewPostInstallationInfoRequest generates requests for PostInstallationInfo
func NewPostInstallationInfoRequest(server string) (*http.Request, error) {
var err error
serverURL, err := url.Parse(server)
if err != nil {
return nil, err
}
operationPath := fmt.Sprintf("/installation_info")
if operationPath[0] == '/' {
operationPath = "." + operationPath
}
queryURL, err := serverURL.Parse(operationPath)
if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", queryURL.String(), nil)
if err != nil {
return nil, err
}
return req, nil
}
// NewPostPathGetRequest generates requests for PostPathGet
func NewPostPathGetRequest(server string) (*http.Request, error) {
var err error
@ -2109,6 +2197,9 @@ type ClientWithResponsesInterface interface {
PostFileSearchWithResponse(ctx context.Context, body PostFileSearchJSONRequestBody, reqEditors ...RequestEditorFn) (*PostFileSearchResponse, error)
// PostInstallationInfoWithResponse request
PostInstallationInfoWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*PostInstallationInfoResponse, error)
// PostPathGetWithResponse request
PostPathGetWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*PostPathGetResponse, error)
@ -2240,6 +2331,28 @@ func (r PostFileSearchResponse) StatusCode() int {
return 0
}
type PostInstallationInfoResponse struct {
Body []byte
HTTPResponse *http.Response
JSON200 *InstallationInfo
}
// Status returns HTTPResponse.Status
func (r PostInstallationInfoResponse) Status() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Status
}
return http.StatusText(0)
}
// StatusCode returns HTTPResponse.StatusCode
func (r PostInstallationInfoResponse) StatusCode() int {
if r.HTTPResponse != nil {
return r.HTTPResponse.StatusCode
}
return 0
}
type PostPathGetResponse struct {
Body []byte
HTTPResponse *http.Response
@ -2513,6 +2626,15 @@ func (c *ClientWithResponses) PostFileSearchWithResponse(ctx context.Context, bo
return ParsePostFileSearchResponse(rsp)
}
// PostInstallationInfoWithResponse request returning *PostInstallationInfoResponse
func (c *ClientWithResponses) PostInstallationInfoWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*PostInstallationInfoResponse, error) {
rsp, err := c.PostInstallationInfo(ctx, reqEditors...)
if err != nil {
return nil, err
}
return ParsePostInstallationInfoResponse(rsp)
}
// PostPathGetWithResponse request returning *PostPathGetResponse
func (c *ClientWithResponses) PostPathGetWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*PostPathGetResponse, error) {
rsp, err := c.PostPathGet(ctx, reqEditors...)
@ -2755,6 +2877,32 @@ func ParsePostFileSearchResponse(rsp *http.Response) (*PostFileSearchResponse, e
return response, nil
}
// ParsePostInstallationInfoResponse parses an HTTP response from a PostInstallationInfoWithResponse call
func ParsePostInstallationInfoResponse(rsp *http.Response) (*PostInstallationInfoResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
defer func() { _ = rsp.Body.Close() }()
if err != nil {
return nil, err
}
response := &PostInstallationInfoResponse{
Body: bodyBytes,
HTTPResponse: rsp,
}
switch {
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
var dest InstallationInfo
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON200 = &dest
}
return response, nil
}
// ParsePostPathGetResponse parses an HTTP response from a PostPathGetWithResponse call
func ParsePostPathGetResponse(rsp *http.Response) (*PostPathGetResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)