summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authoradamdottv <[email protected]>2025-06-18 13:56:46 -0500
committeradamdottv <[email protected]>2025-06-18 13:56:51 -0500
commitbd46cf0f868293b501874c1f04632ced3bec7b81 (patch)
treebcd5ed78fc41bcf73c0ffd76086bf10c34ed700b
parentd4157d9a9603c099e650af4f6c369a56d3878179 (diff)
downloadopencode-bd46cf0f868293b501874c1f04632ced3bec7b81.tar.gz
opencode-bd46cf0f868293b501874c1f04632ced3bec7b81.zip
feat(tui): configurable keybinds and mouse scroll
-rw-r--r--.gitignore2
-rw-r--r--README.md44
-rw-r--r--packages/tui/cmd/opencode/main.go45
-rw-r--r--packages/tui/internal/app/app.go70
-rw-r--r--packages/tui/internal/commands/command.go326
-rw-r--r--packages/tui/internal/completions/commands.go23
-rw-r--r--packages/tui/internal/components/chat/chat.go22
-rw-r--r--packages/tui/internal/components/chat/editor.go371
-rw-r--r--packages/tui/internal/components/chat/message.go30
-rw-r--r--packages/tui/internal/components/chat/messages.go120
-rw-r--r--packages/tui/internal/components/dialog/complete.go9
-rw-r--r--packages/tui/internal/components/dialog/help.go54
-rw-r--r--packages/tui/internal/config/config.go67
-rw-r--r--packages/tui/internal/layout/container.go9
-rw-r--r--packages/tui/internal/layout/flex.go137
-rw-r--r--packages/tui/internal/layout/layout.go8
-rw-r--r--packages/tui/internal/tui/tui.go571
-rw-r--r--packages/tui/pkg/client/gen/openapi.json73
-rw-r--r--packages/tui/pkg/client/generated-client.go148
19 files changed, 1276 insertions, 853 deletions
diff --git a/.gitignore b/.gitignore
index f76ce40c7..a07a7493f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,5 +5,3 @@ node_modules
.env
.idea
.vscode
-app.log
-gopls.log \ No newline at end of file
diff --git a/README.md b/README.md
index 5fa27d913..09ddb2675 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/packages/tui/cmd/opencode/main.go b/packages/tui/cmd/opencode/main.go
index a46664806..1f25484ea 100644
--- a/packages/tui/cmd/opencode/main.go
+++ b/packages/tui/cmd/opencode/main.go
@@ -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)
diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go
index 8db93381a..940ac7b2f 100644
--- a/packages/tui/internal/app/app.go
+++ b/packages/tui/internal/app/app.go
@@ -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() {
+//
+// }
diff --git a/packages/tui/internal/commands/command.go b/packages/tui/internal/commands/command.go
index 792ab9464..92e11c79f 100644
--- a/packages/tui/internal/commands/command.go
+++ b/packages/tui/internal/commands/command.go
@@ -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
+func (c Command) Keys() []string {
+ var keys []string
+ for _, k := range c.Keybindings {
+ keys = append(keys, k.Key)
+ }
+ return keys
+}
+
+type CommandRegistry map[CommandName]Command
-// ExecuteCommandMsg is a message sent when a command should be executed.
-type ExecuteCommandMsg struct {
- Name string
+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
}
-func NewCommandRegistry() Registry {
- return Registry{
- "help": {
- Name: "help",
+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"),
- ),
- },
- "sessions": {
- Name: "sessions",
- Description: "switch session",
- KeyBinding: key.NewBinding(
- key.WithKeys("f3", "super+s"),
- ),
- },
- "model": {
- Name: "model",
- Description: "switch model",
- KeyBinding: key.NewBinding(
- key.WithKeys("f4", "super+m"),
- ),
- },
- "theme": {
- Name: "theme",
- Description: "switch theme",
- KeyBinding: key.NewBinding(
- key.WithKeys("f5", "super+t"),
- ),
- },
- "share": {
- Name: "share",
- Description: "create shareable link",
- KeyBinding: key.NewBinding(
- key.WithKeys("f6"),
- ),
- },
- "init": {
- Name: "init",
+ Keybindings: parseBindings("<leader>n"),
+ Trigger: "new",
+ },
+ {
+ Name: SessionListCommand,
+ Description: "list sessions",
+ Keybindings: parseBindings("<leader>l"),
+ Trigger: "sessions",
+ },
+ {
+ Name: SessionShareCommand,
+ Description: "share session",
+ Keybindings: parseBindings("<leader>s"),
+ Trigger: "share",
+ },
+ {
+ Name: SessionInterruptCommand,
+ Description: "interrupt session",
+ Keybindings: parseBindings("esc"),
+ },
+ {
+ Name: SessionCompactCommand,
+ Description: "compact the session",
+ Keybindings: parseBindings("<leader>c"),
+ Trigger: "compact",
+ },
+ {
+ 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"),
- ),
- },
- // "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"),
- ),
+ Keybindings: parseBindings("<leader>i"),
+ Trigger: "init",
+ },
+ {
+ 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
}
diff --git a/packages/tui/internal/completions/commands.go b/packages/tui/internal/completions/commands.go
index 00e8847f4..e3215a88f 100644
--- a/packages/tui/internal/completions/commands.go
+++ b/packages/tui/internal/completions/commands.go
@@ -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
}
diff --git a/packages/tui/internal/components/chat/chat.go b/packages/tui/internal/components/chat/chat.go
deleted file mode 100644
index 29487efb7..000000000
--- a/packages/tui/internal/components/chat/chat.go
+++ /dev/null
@@ -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)
-}
diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go
index a2d33f172..46d160478 100644
--- a/packages/tui/internal/components/chat/editor.go
+++ b/packages/tui/internal/components/chat/editor.go
@@ -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"
+func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
+ value := m.Value()
+ m.textarea.Reset()
+ if value == "" {
+ return m, nil
}
-
- tmpfile, err := os.CreateTemp("", "msg_*.md")
- tmpfile.WriteString(value)
- if err != nil {
- // status.Error(err.Error())
- return 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
}
- 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())
- m.textarea.Reset()
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)
diff --git a/packages/tui/internal/components/chat/message.go b/packages/tui/internal/components/chat/message.go
index 9ff06e74c..4104e028e 100644
--- a/packages/tui/internal/components/chat/message.go
+++ b/packages/tui/internal/components/chat/message.go
@@ -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":
diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go
index 577592b7e..3016c24d9 100644
--- a/packages/tui/internal/components/chat/messages.go
+++ b/packages/tui/internal/components/chat/messages.go
@@ -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,
}
diff --git a/packages/tui/internal/components/dialog/complete.go b/packages/tui/internal/components/dialog/complete.go
index ca86b00e6..c627e96e9 100644
--- a/packages/tui/internal/components/dialog/complete.go
+++ b/packages/tui/internal/components/dialog/complete.go
@@ -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
diff --git a/packages/tui/internal/components/dialog/help.go b/packages/tui/internal/components/dialog/help.go
index 2ef887890..1886714e4 100644
--- a/packages/tui/internal/components/dialog/help.go
+++ b/packages/tui/internal/components/dialog/help.go
@@ -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(),
}
}
diff --git a/packages/tui/internal/config/config.go b/packages/tui/internal/config/config.go
index ad7b18d07..6474f75eb 100644
--- a/packages/tui/internal/config/config.go
+++ b/packages/tui/internal/config/config.go
@@ -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.
+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",
+ }
+}
+
func NewConfig() *Config {
+ keybinds := make(map[string]string)
+ keybinds["leader"] = "ctrl+x"
return &Config{
- Theme: "opencode",
+ Keybinds: keybinds,
+ }
+}
+
+func ConfigToState(config *Config) *State {
+ return &State{
+ Theme: config.Theme,
+ Provider: config.Provider,
+ Model: config.Model,
}
}
-// SaveConfig writes the provided Config struct to the specified TOML file.
+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
+}
diff --git a/packages/tui/internal/layout/container.go b/packages/tui/internal/layout/container.go
index 14434124a..3eda158cc 100644
--- a/packages/tui/internal/layout/container.go
+++ b/packages/tui/internal/layout/container.go
@@ -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 {
diff --git a/packages/tui/internal/layout/flex.go b/packages/tui/internal/layout/flex.go
index 0d337be2a..35f6ba164 100644
--- a/packages/tui/internal/layout/flex.go
+++ b/packages/tui/internal/layout/flex.go
@@ -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
}
diff --git a/packages/tui/internal/layout/layout.go b/packages/tui/internal/layout/layout.go
index f2c16c603..208faaa2f 100644
--- a/packages/tui/internal/layout/layout.go
+++ b/packages/tui/internal/layout/layout.go
@@ -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 {
diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go
index c5aae2596..3b6b44eff 100644
--- a/packages/tui/internal/tui/tui.go
+++ b/packages/tui/internal/tui/tui.go
@@ -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
- }
+ // Pass all other key presses to the modal
+ updatedModal, cmd := a.modal.Update(msg)
+ a.modal = updatedModal.(layout.Modal)
+ return a, cmd
+ }
+
+ // 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))
}
}
- // 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
+ // 3. Handle completions trigger
+ switch msg.String() {
+ case "/":
+ a.showCompletionDialog = true
}
- if !bypassModal {
- updatedModal, cmd := a.modal.Update(msg)
- a.modal = updatedModal.(layout.Modal)
- return a, cmd
+ 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...)
+ // }
}
- }
- switch msg := msg.(type) {
- case chat.SendMsg:
- a.showCompletionDialog = false
- cmd := a.sendMessage(msg.Text, msg.Attachments)
- if cmd != nil {
- return a, cmd
+ // 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...)
}
- 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),
- ))
- }
- helpDialog := dialog.NewHelpDialog(helpBindings...)
- a.modal = helpDialog
+
+ // 5. Check for leader key activation
+ if a.leaderBinding != nil &&
+ !a.isLeaderSequence &&
+ key.Matches(msg, *a.leaderBinding) {
+ a.isLeaderSequence = true
+ return a, nil
}
- slog.Info("Execute command", "cmds", cmds)
- return a, tea.Batch(cmds...)
+ // 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),
),
),
}
diff --git a/packages/tui/pkg/client/gen/openapi.json b/packages/tui/pkg/client/gen/openapi.json
index 9cbfc70e2..0afe49373 100644
--- a/packages/tui/pkg/client/gen/openapi.json
+++ b/packages/tui/pkg/client/gen/openapi.json
@@ -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"
+ ]
}
}
}
diff --git a/packages/tui/pkg/client/generated-client.go b/packages/tui/pkg/client/generated-client.go
index 59332f0a9..4f85f858e 100644
--- a/packages/tui/pkg/client/generated-client.go
+++ b/packages/tui/pkg/client/generated-client.go
@@ -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)