Просмотр исходного кода

feat(tui): configurable keybinds and mouse scroll

adamdottv 8 месяцев назад
Родитель
Сommit
bd46cf0f86

+ 0 - 2
.gitignore

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

+ 42 - 2
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

+ 19 - 26
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)

+ 48 - 22
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() {
+//
+// }

+ 254 - 72
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
 }

+ 14 - 9
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
 }

+ 0 - 22
packages/tui/internal/components/chat/chat.go

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

+ 114 - 257
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)

+ 23 - 7
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":

+ 60 - 60
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,
 	}

+ 7 - 2
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

+ 20 - 34
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(),
 	}
 }

+ 57 - 10
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
+}

+ 6 - 3
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 {

+ 59 - 78
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
 	}

+ 8 - 0
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 {

+ 326 - 245
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),
 			),
 		),
 	}

+ 71 - 2
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"
+        ]
       }
     }
   }

+ 148 - 0
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)