瀏覽代碼

wip: refactoring tui

adamdottv 8 月之前
父節點
當前提交
62b9a30a9c

+ 0 - 3
packages/tui/cmd/opencode/main.go

@@ -123,9 +123,6 @@ func main() {
 		// Cancel subscriptions first
 		cancelSubs()
 
-		// Then shutdown the app
-		app_.Shutdown()
-
 		// Then cancel TUI message handler
 		tuiCancel()
 

+ 3 - 29
packages/tui/internal/app/app.go

@@ -9,6 +9,7 @@ import (
 	"log/slog"
 
 	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/sst/opencode/internal/commands"
 	"github.com/sst/opencode/internal/config"
 	"github.com/sst/opencode/internal/state"
 	"github.com/sst/opencode/internal/status"
@@ -26,10 +27,7 @@ type App struct {
 	Session    *client.SessionInfo
 	Messages   []client.MessageInfo
 	Status     status.Service
-
-	// UI state
-	filepickerOpen       bool
-	completionDialogOpen bool
+	Commands   commands.Registry
 }
 
 type AppInfo struct {
@@ -117,6 +115,7 @@ func New(ctx context.Context, version string, httpClient *client.ClientWithRespo
 		Session:    &client.SessionInfo{},
 		Messages:   []client.MessageInfo{},
 		Status:     status.GetService(),
+		Commands:   commands.NewCommandRegistry(),
 	}
 
 	theme.SetTheme(appConfig.Theme)
@@ -324,28 +323,3 @@ func (a *App) ListProviders(ctx context.Context) ([]client.ProviderInfo, error)
 	providers := *resp.JSON200
 	return providers.Providers, nil
 }
-
-// IsFilepickerOpen returns whether the filepicker is currently open
-func (app *App) IsFilepickerOpen() bool {
-	return app.filepickerOpen
-}
-
-// SetFilepickerOpen sets the state of the filepicker
-func (app *App) SetFilepickerOpen(open bool) {
-	app.filepickerOpen = open
-}
-
-// IsCompletionDialogOpen returns whether the completion dialog is currently open
-func (app *App) IsCompletionDialogOpen() bool {
-	return app.completionDialogOpen
-}
-
-// SetCompletionDialogOpen sets the state of the completion dialog
-func (app *App) SetCompletionDialogOpen(open bool) {
-	app.completionDialogOpen = open
-}
-
-// Shutdown performs a clean shutdown of the application
-func (app *App) Shutdown() {
-	// TODO: cleanup?
-}

+ 50 - 4
packages/tui/internal/commands/command.go

@@ -15,11 +15,57 @@ type Command struct {
 }
 
 // Registry holds all the available commands.
-type Registry struct {
-	Commands map[string]Command
-}
+type Registry map[string]Command
 
 // ExecuteCommandMsg is a message sent when a command should be executed.
 type ExecuteCommandMsg struct {
 	Name string
-}
+}
+
+func NewCommandRegistry() Registry {
+	return Registry{
+		"help": {
+			Name:        "help",
+			Description: "show help",
+			KeyBinding: key.NewBinding(
+				key.WithKeys("f1", "super+/", "super+h"),
+			),
+		},
+		"new": {
+			Name:        "new",
+			Description: "new session",
+			KeyBinding: key.NewBinding(
+				key.WithKeys("f2", "super+n"),
+			),
+		},
+		"sessions": {
+			Name:        "sessions",
+			Description: "switch session",
+			KeyBinding: key.NewBinding(
+				key.WithKeys("f3", "super+s"),
+			),
+		},
+		"model": {
+			Name:        "model",
+			Description: "switch model",
+			KeyBinding: key.NewBinding(
+				key.WithKeys("f4", "super+m"),
+			),
+		},
+		"theme": {
+			Name:        "theme",
+			Description: "switch theme",
+			KeyBinding: key.NewBinding(
+				key.WithKeys("f5", "super+t"),
+			),
+		},
+		"quit": {
+			Name:        "quit",
+			Description: "quit",
+			KeyBinding: key.NewBinding(
+				key.WithKeys("f10", "ctrl+c", "super+q"),
+			),
+		},
+	}
+}
+

+ 9 - 16
packages/tui/internal/components/chat/editor.go

@@ -112,12 +112,6 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
 		m.textarea.SetValue(modifiedValue)
 		return m, nil
-	case dialog.AttachmentAddedMsg:
-		if len(m.attachments) >= maxAttachments {
-			status.Error(fmt.Sprintf("cannot add more than %d images", maxAttachments))
-			return m, cmd
-		}
-		m.attachments = append(m.attachments, msg.Attachment)
 	case tea.KeyMsg:
 		switch msg.String() {
 		case "ctrl+c":
@@ -190,7 +184,9 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.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) && !m.app.IsFilepickerOpen() && !m.app.IsCompletionDialogOpen() {
+		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()
 
@@ -210,7 +206,9 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 		}
 
-		if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryDown) && !m.app.IsFilepickerOpen() && !m.app.IsCompletionDialogOpen() {
+		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()
@@ -317,13 +315,6 @@ func (m *editorComponent) GetSize() (int, int) {
 	return m.width, m.height
 }
 
-func (m *editorComponent) BindingKeys() []key.Binding {
-	bindings := []key.Binding{}
-	bindings = append(bindings, layout.KeyMapToSlice(editorMaps)...)
-	bindings = append(bindings, layout.KeyMapToSlice(DeleteKeyMaps)...)
-	return bindings
-}
-
 func (m *editorComponent) openEditor(value string) tea.Cmd {
 	editor := os.Getenv("EDITOR")
 	if editor == "" {
@@ -387,7 +378,9 @@ func (m *editorComponent) send() tea.Cmd {
 	// Check for slash command
 	if strings.HasPrefix(value, "/") {
 		commandName := strings.TrimPrefix(value, "/")
-		return util.CmdHandler(commands.ExecuteCommandMsg{Name: commandName})
+		if _, ok := m.app.Commands[commandName]; ok {
+			return util.CmdHandler(commands.ExecuteCommandMsg{Name: commandName})
+		}
 	}
 
 	return tea.Batch(

+ 0 - 9
packages/tui/internal/components/chat/messages.go

@@ -384,15 +384,6 @@ func (m *messagesComponent) Reload() tea.Cmd {
 	}
 }
 
-func (m *messagesComponent) BindingKeys() []key.Binding {
-	return []key.Binding{
-		m.viewport.KeyMap.PageDown,
-		m.viewport.KeyMap.PageUp,
-		m.viewport.KeyMap.HalfPageUp,
-		m.viewport.KeyMap.HalfPageDown,
-	}
-}
-
 func NewMessagesComponent(app *app.App) layout.ModelWithView {
 	customSpinner := spinner.Spinner{
 		Frames: []string{" ", "┃", "┃"},

+ 16 - 20
packages/tui/internal/components/dialog/complete.go

@@ -20,7 +20,7 @@ type CompletionItem struct {
 }
 
 type CompletionItemI interface {
-	utilComponents.SimpleListItem
+	utilComponents.ListItem
 	GetValue() string
 	DisplayValue() string
 }
@@ -78,8 +78,8 @@ type CompletionDialogCloseMsg struct{}
 
 type CompletionDialog interface {
 	layout.ModelWithView
-	layout.Bindings
 	SetWidth(width int)
+	IsEmpty() bool
 }
 
 type completionDialogComponent struct {
@@ -88,7 +88,7 @@ type completionDialogComponent struct {
 	width                int
 	height               int
 	pseudoSearchTextArea textarea.Model
-	listView             utilComponents.SimpleList[CompletionItemI]
+	list                 utilComponents.List[CompletionItemI]
 }
 
 type completionDialogKeyMap struct {
@@ -126,7 +126,7 @@ func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd {
 }
 
 func (c *completionDialogComponent) close() tea.Cmd {
-	c.listView.SetItems([]CompletionItemI{})
+	c.list.SetItems([]CompletionItemI{})
 	c.pseudoSearchTextArea.Reset()
 	c.pseudoSearchTextArea.Blur()
 
@@ -138,9 +138,7 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.KeyMsg:
 		if c.pseudoSearchTextArea.Focused() {
-
 			if !key.Matches(msg, completionDialogKeys.Complete) {
-
 				var cmd tea.Cmd
 				c.pseudoSearchTextArea, cmd = c.pseudoSearchTextArea.Update(msg)
 				cmds = append(cmds, cmd)
@@ -157,25 +155,23 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 						status.Error(err.Error())
 					}
 
-					c.listView.SetItems(items)
+					c.list.SetItems(items)
 					c.query = query
 				}
 
-				u, cmd := c.listView.Update(msg)
-				c.listView = u.(utilComponents.SimpleList[CompletionItemI])
+				u, cmd := c.list.Update(msg)
+				c.list = u.(utilComponents.List[CompletionItemI])
 
 				cmds = append(cmds, cmd)
 			}
 
 			switch {
 			case key.Matches(msg, completionDialogKeys.Complete):
-				item, i := c.listView.GetSelectedItem()
+				item, i := c.list.GetSelectedItem()
 				if i == -1 {
 					return c, nil
 				}
-
 				cmd := c.complete(item)
-
 				return c, cmd
 			case key.Matches(msg, completionDialogKeys.Cancel):
 				// Only close on backspace when there are no characters left
@@ -191,7 +187,7 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				status.Error(err.Error())
 			}
 
-			c.listView.SetItems(items)
+			c.list.SetItems(items)
 			c.pseudoSearchTextArea.SetValue(msg.String())
 			return c, c.pseudoSearchTextArea.Focus()
 		}
@@ -209,7 +205,7 @@ func (c *completionDialogComponent) View() string {
 
 	maxWidth := 40
 
-	completions := c.listView.GetItems()
+	completions := c.list.GetItems()
 
 	for _, cmd := range completions {
 		title := cmd.DisplayValue()
@@ -218,7 +214,7 @@ func (c *completionDialogComponent) View() string {
 		}
 	}
 
-	c.listView.SetMaxWidth(maxWidth)
+	c.list.SetMaxWidth(maxWidth)
 
 	return baseStyle.Padding(0, 0).
 		Border(lipgloss.NormalBorder()).
@@ -228,15 +224,15 @@ func (c *completionDialogComponent) View() string {
 		BorderBackground(t.Background()).
 		BorderForeground(t.TextMuted()).
 		Width(c.width).
-		Render(c.listView.View())
+		Render(c.list.View())
 }
 
 func (c *completionDialogComponent) SetWidth(width int) {
 	c.width = width
 }
 
-func (c *completionDialogComponent) BindingKeys() []key.Binding {
-	return layout.KeyMapToSlice(completionDialogKeys)
+func (c *completionDialogComponent) IsEmpty() bool {
+	return c.list.IsEmpty()
 }
 
 func NewCompletionDialogComponent(completionProvider CompletionProvider) CompletionDialog {
@@ -247,7 +243,7 @@ func NewCompletionDialogComponent(completionProvider CompletionProvider) Complet
 		status.Error(err.Error())
 	}
 
-	li := utilComponents.NewSimpleList(
+	li := utilComponents.NewListComponent(
 		items,
 		7,
 		"No file matches found",
@@ -258,6 +254,6 @@ func NewCompletionDialogComponent(completionProvider CompletionProvider) Complet
 		query:                "",
 		completionProvider:   completionProvider,
 		pseudoSearchTextArea: ti,
-		listView:             li,
+		list:                 li,
 	}
 }

+ 0 - 486
packages/tui/internal/components/dialog/filepicker.go

@@ -1,486 +0,0 @@
-package dialog
-
-import (
-	"fmt"
-	"net/http"
-	"os"
-	"path/filepath"
-	"sort"
-	"strings"
-	"time"
-
-	"log/slog"
-
-	"github.com/atotto/clipboard"
-	"github.com/charmbracelet/bubbles/v2/key"
-	"github.com/charmbracelet/bubbles/v2/textinput"
-	"github.com/charmbracelet/bubbles/v2/viewport"
-	tea "github.com/charmbracelet/bubbletea/v2"
-	"github.com/charmbracelet/lipgloss/v2"
-	"github.com/sst/opencode/internal/app"
-	"github.com/sst/opencode/internal/image"
-	"github.com/sst/opencode/internal/layout"
-	"github.com/sst/opencode/internal/status"
-	"github.com/sst/opencode/internal/styles"
-	"github.com/sst/opencode/internal/theme"
-	"github.com/sst/opencode/internal/util"
-)
-
-const (
-	maxAttachmentSize = int64(5 * 1024 * 1024) // 5MB
-	downArrow         = "down"
-	upArrow           = "up"
-)
-
-type FilePrickerKeyMap struct {
-	Enter          key.Binding
-	Down           key.Binding
-	Up             key.Binding
-	Forward        key.Binding
-	Backward       key.Binding
-	OpenFilePicker key.Binding
-	Esc            key.Binding
-	InsertCWD      key.Binding
-	Paste          key.Binding
-}
-
-var filePickerKeyMap = FilePrickerKeyMap{
-	Enter: key.NewBinding(
-		key.WithKeys("enter"),
-		key.WithHelp("enter", "select file/enter directory"),
-	),
-	Down: key.NewBinding(
-		key.WithKeys("j", downArrow),
-		key.WithHelp("↓/j", "down"),
-	),
-	Up: key.NewBinding(
-		key.WithKeys("k", upArrow),
-		key.WithHelp("↑/k", "up"),
-	),
-	Forward: key.NewBinding(
-		key.WithKeys("l"),
-		key.WithHelp("l", "enter directory"),
-	),
-	Backward: key.NewBinding(
-		key.WithKeys("h", "backspace"),
-		key.WithHelp("h/backspace", "go back"),
-	),
-	OpenFilePicker: key.NewBinding(
-		key.WithKeys("ctrl+f"),
-		key.WithHelp("ctrl+f", "open file picker"),
-	),
-	Esc: key.NewBinding(
-		key.WithKeys("esc"),
-		key.WithHelp("esc", "close/exit"),
-	),
-	InsertCWD: key.NewBinding(
-		key.WithKeys("i"),
-		key.WithHelp("i", "manual path input"),
-	),
-	Paste: key.NewBinding(
-		key.WithKeys("ctrl+v"),
-		key.WithHelp("ctrl+v", "paste file/directory path"),
-	),
-}
-
-type filepickerComponent struct {
-	basePath       string
-	width          int
-	height         int
-	cursor         int
-	err            error
-	cursorChain    stack
-	viewport       viewport.Model
-	dirs           []os.DirEntry
-	cwdDetails     *DirNode
-	selectedFile   string
-	cwd            textinput.Model
-	ShowFilePicker bool
-	app            *app.App
-}
-
-type DirNode struct {
-	parent    *DirNode
-	child     *DirNode
-	directory string
-}
-type stack []int
-
-func (s stack) Push(v int) stack {
-	return append(s, v)
-}
-
-func (s stack) Pop() (stack, int) {
-	l := len(s)
-	return s[:l-1], s[l-1]
-}
-
-type AttachmentAddedMsg struct {
-	Attachment app.Attachment
-}
-
-func (f *filepickerComponent) Init() tea.Cmd {
-	return nil
-}
-
-func (f *filepickerComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	var cmd tea.Cmd
-	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		f.width = 60
-		f.height = 20
-		f.viewport.SetWidth(80)
-		f.viewport.SetHeight(22)
-		f.cursor = 0
-		f.getCurrentFileBelowCursor()
-	case tea.KeyMsg:
-		if f.cwd.Focused() {
-			f.cwd, cmd = f.cwd.Update(msg)
-		}
-		switch {
-		case key.Matches(msg, filePickerKeyMap.InsertCWD):
-			f.cwd.Focus()
-			return f, cmd
-		case key.Matches(msg, filePickerKeyMap.Esc):
-			if f.cwd.Focused() {
-				f.cwd.Blur()
-			}
-		case key.Matches(msg, filePickerKeyMap.Down):
-			if !f.cwd.Focused() || msg.String() == downArrow {
-				if f.cursor < len(f.dirs)-1 {
-					f.cursor++
-					f.getCurrentFileBelowCursor()
-				}
-			}
-		case key.Matches(msg, filePickerKeyMap.Up):
-			if !f.cwd.Focused() || msg.String() == upArrow {
-				if f.cursor > 0 {
-					f.cursor--
-					f.getCurrentFileBelowCursor()
-				}
-			}
-		case key.Matches(msg, filePickerKeyMap.Enter):
-			var path string
-			var isPathDir bool
-			if f.cwd.Focused() {
-				path = f.cwd.Value()
-				fileInfo, err := os.Stat(path)
-				if err != nil {
-					status.Error("Invalid path")
-					return f, cmd
-				}
-				isPathDir = fileInfo.IsDir()
-			} else {
-				path = filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name())
-				isPathDir = f.dirs[f.cursor].IsDir()
-			}
-			if isPathDir {
-				newWorkingDir := DirNode{parent: f.cwdDetails, directory: path}
-				f.cwdDetails.child = &newWorkingDir
-				f.cwdDetails = f.cwdDetails.child
-				f.cursorChain = f.cursorChain.Push(f.cursor)
-				f.dirs = readDir(f.cwdDetails.directory, false)
-				f.cursor = 0
-				f.cwd.SetValue(f.cwdDetails.directory)
-				f.getCurrentFileBelowCursor()
-			} else {
-				f.selectedFile = path
-				return f.addAttachmentToMessage()
-			}
-		case key.Matches(msg, filePickerKeyMap.Esc):
-			if !f.cwd.Focused() {
-				f.cursorChain = make(stack, 0)
-				f.cursor = 0
-			} else {
-				f.cwd.Blur()
-			}
-		case key.Matches(msg, filePickerKeyMap.Forward):
-			if !f.cwd.Focused() {
-				if f.dirs[f.cursor].IsDir() {
-					path := filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name())
-					newWorkingDir := DirNode{parent: f.cwdDetails, directory: path}
-					f.cwdDetails.child = &newWorkingDir
-					f.cwdDetails = f.cwdDetails.child
-					f.cursorChain = f.cursorChain.Push(f.cursor)
-					f.dirs = readDir(f.cwdDetails.directory, false)
-					f.cursor = 0
-					f.cwd.SetValue(f.cwdDetails.directory)
-					f.getCurrentFileBelowCursor()
-				}
-			}
-		case key.Matches(msg, filePickerKeyMap.Backward):
-			if !f.cwd.Focused() {
-				if len(f.cursorChain) != 0 && f.cwdDetails.parent != nil {
-					f.cursorChain, f.cursor = f.cursorChain.Pop()
-					f.cwdDetails = f.cwdDetails.parent
-					f.cwdDetails.child = nil
-					f.dirs = readDir(f.cwdDetails.directory, false)
-					f.cwd.SetValue(f.cwdDetails.directory)
-					f.getCurrentFileBelowCursor()
-				}
-			}
-		case key.Matches(msg, filePickerKeyMap.Paste):
-			if f.cwd.Focused() {
-				val, err := clipboard.ReadAll()
-				if err != nil {
-					slog.Error("failed to read clipboard")
-					return f, cmd
-				}
-				f.cwd.SetValue(f.cwd.Value() + val)
-			}
-		case key.Matches(msg, filePickerKeyMap.OpenFilePicker):
-			f.dirs = readDir(f.cwdDetails.directory, false)
-			f.cursor = 0
-			f.getCurrentFileBelowCursor()
-		}
-	}
-	return f, cmd
-}
-
-func (f *filepickerComponent) addAttachmentToMessage() (tea.Model, tea.Cmd) {
-	// modeInfo := GetSelectedModel(config.Get())
-	// if !modeInfo.SupportsAttachments {
-	// 	status.Error(fmt.Sprintf("Model %s doesn't support attachments", modeInfo.Name))
-	// 	return f, nil
-	// }
-
-	selectedFilePath := f.selectedFile
-	if !isExtSupported(selectedFilePath) {
-		status.Error("Unsupported file")
-		return f, nil
-	}
-
-	isFileLarge, err := image.ValidateFileSize(selectedFilePath, maxAttachmentSize)
-	if err != nil {
-		status.Error("unable to read the image")
-		return f, nil
-	}
-	if isFileLarge {
-		status.Error("file too large, max 5MB")
-		return f, nil
-	}
-
-	content, err := os.ReadFile(selectedFilePath)
-	if err != nil {
-		status.Error("Unable read selected file")
-		return f, nil
-	}
-
-	mimeBufferSize := min(512, len(content))
-	mimeType := http.DetectContentType(content[:mimeBufferSize])
-	fileName := filepath.Base(selectedFilePath)
-	attachment := app.Attachment{FilePath: selectedFilePath, FileName: fileName, MimeType: mimeType, Content: content}
-	f.selectedFile = ""
-	return f, util.CmdHandler(AttachmentAddedMsg{attachment})
-}
-
-func (f *filepickerComponent) View() string {
-	t := theme.CurrentTheme()
-	const maxVisibleDirs = 20
-	const maxWidth = 80
-
-	adjustedWidth := maxWidth
-	for _, file := range f.dirs {
-		if len(file.Name()) > adjustedWidth-4 { // Account for padding
-			adjustedWidth = len(file.Name()) + 4
-		}
-	}
-	adjustedWidth = max(30, min(adjustedWidth, f.width-15)) + 1
-
-	files := make([]string, 0, maxVisibleDirs)
-	startIdx := 0
-
-	if len(f.dirs) > maxVisibleDirs {
-		halfVisible := maxVisibleDirs / 2
-		if f.cursor >= halfVisible && f.cursor < len(f.dirs)-halfVisible {
-			startIdx = f.cursor - halfVisible
-		} else if f.cursor >= len(f.dirs)-halfVisible {
-			startIdx = len(f.dirs) - maxVisibleDirs
-		}
-	}
-
-	endIdx := min(startIdx+maxVisibleDirs, len(f.dirs))
-
-	for i := startIdx; i < endIdx; i++ {
-		file := f.dirs[i]
-		itemStyle := styles.BaseStyle().Width(adjustedWidth)
-
-		if i == f.cursor {
-			itemStyle = itemStyle.
-				Background(t.Primary()).
-				Foreground(t.Background()).
-				Bold(true)
-		}
-		filename := file.Name()
-
-		if len(filename) > adjustedWidth-4 {
-			filename = filename[:adjustedWidth-7] + "..."
-		}
-		if file.IsDir() {
-			filename = filename + "/"
-		}
-
-		files = append(files, itemStyle.Padding(0, 1).Render(filename))
-	}
-
-	// Pad to always show exactly 21 lines
-	for len(files) < maxVisibleDirs {
-		files = append(files, styles.BaseStyle().Width(adjustedWidth).Render(""))
-	}
-
-	currentPath := styles.BaseStyle().
-		Height(1).
-		Width(adjustedWidth).
-		Render(f.cwd.View())
-
-	viewportstyle := lipgloss.NewStyle().
-		Width(f.viewport.Width()).
-		Background(t.Background()).
-		Border(lipgloss.RoundedBorder()).
-		BorderForeground(t.TextMuted()).
-		BorderBackground(t.Background()).
-		Padding(2).
-		Render(f.viewport.View())
-	var insertExitText string
-	if f.IsCWDFocused() {
-		insertExitText = "Press esc to exit typing path"
-	} else {
-		insertExitText = "Press i to start typing path"
-	}
-
-	content := lipgloss.JoinVertical(
-		lipgloss.Left,
-		currentPath,
-		styles.BaseStyle().Width(adjustedWidth).Render(""),
-		styles.BaseStyle().Width(adjustedWidth).Render(lipgloss.JoinVertical(lipgloss.Left, files...)),
-		styles.BaseStyle().Width(adjustedWidth).Render(""),
-		styles.BaseStyle().Foreground(t.TextMuted()).Width(adjustedWidth).Render(insertExitText),
-	)
-
-	f.cwd.SetValue(f.cwd.Value())
-	contentStyle := styles.BaseStyle().Padding(1, 2).
-		Border(lipgloss.RoundedBorder()).
-		BorderBackground(t.Background()).
-		BorderForeground(t.TextMuted()).
-		Width(lipgloss.Width(content) + 4)
-
-	return lipgloss.JoinHorizontal(lipgloss.Center, contentStyle.Render(content), viewportstyle)
-}
-
-type FilepickerComponent interface {
-	layout.ModelWithView
-	ToggleFilepicker(showFilepicker bool)
-	IsCWDFocused() bool
-}
-
-func (f *filepickerComponent) ToggleFilepicker(showFilepicker bool) {
-	f.ShowFilePicker = showFilepicker
-}
-
-func (f *filepickerComponent) IsCWDFocused() bool {
-	return f.cwd.Focused()
-}
-
-func NewFilepickerCmp(app *app.App) FilepickerComponent {
-	homepath, err := os.UserHomeDir()
-	if err != nil {
-		slog.Error("error loading user files")
-		return nil
-	}
-	baseDir := DirNode{parent: nil, directory: homepath}
-	dirs := readDir(homepath, false)
-	viewport := viewport.New() // viewport.New(0, 0)
-	currentDirectory := textinput.New()
-	currentDirectory.CharLimit = 200
-	currentDirectory.SetWidth(44)
-	// currentDirectory.Cursor.Blink = true
-	currentDirectory.SetValue(baseDir.directory)
-	return &filepickerComponent{cwdDetails: &baseDir, dirs: dirs, cursorChain: make(stack, 0), viewport: viewport, cwd: currentDirectory, app: app}
-}
-
-func (f *filepickerComponent) getCurrentFileBelowCursor() {
-	if len(f.dirs) == 0 || f.cursor < 0 || f.cursor >= len(f.dirs) {
-		slog.Error(fmt.Sprintf("Invalid cursor position. Dirs length: %d, Cursor: %d", len(f.dirs), f.cursor))
-		f.viewport.SetContent("Preview unavailable")
-		return
-	}
-
-	dir := f.dirs[f.cursor]
-	filename := dir.Name()
-	if !dir.IsDir() && isExtSupported(filename) {
-		fullPath := f.cwdDetails.directory + "/" + dir.Name()
-
-		go func() {
-			imageString, err := image.ImagePreview(f.viewport.Width()-4, fullPath)
-			if err != nil {
-				slog.Error(err.Error())
-				f.viewport.SetContent("Preview unavailable")
-				return
-			}
-
-			f.viewport.SetContent(imageString)
-		}()
-	} else {
-		f.viewport.SetContent("Preview unavailable")
-	}
-}
-
-func readDir(path string, showHidden bool) []os.DirEntry {
-	slog.Info(fmt.Sprintf("Reading directory: %s", path))
-
-	entriesChan := make(chan []os.DirEntry, 1)
-	errChan := make(chan error, 1)
-
-	go func() {
-		dirEntries, err := os.ReadDir(path)
-		if err != nil {
-			status.Error(err.Error())
-			errChan <- err
-			return
-		}
-		entriesChan <- dirEntries
-	}()
-
-	select {
-	case dirEntries := <-entriesChan:
-		sort.Slice(dirEntries, func(i, j int) bool {
-			if dirEntries[i].IsDir() == dirEntries[j].IsDir() {
-				return dirEntries[i].Name() < dirEntries[j].Name()
-			}
-			return dirEntries[i].IsDir()
-		})
-
-		if showHidden {
-			return dirEntries
-		}
-
-		var sanitizedDirEntries []os.DirEntry
-		for _, dirEntry := range dirEntries {
-			isHidden, _ := IsHidden(dirEntry.Name())
-			if !isHidden {
-				if dirEntry.IsDir() || isExtSupported(dirEntry.Name()) {
-					sanitizedDirEntries = append(sanitizedDirEntries, dirEntry)
-				}
-			}
-		}
-
-		return sanitizedDirEntries
-
-	case <-errChan:
-		status.Error(fmt.Sprintf("Error reading directory %s", path))
-		return []os.DirEntry{}
-
-	case <-time.After(5 * time.Second):
-		status.Error(fmt.Sprintf("Timeout reading directory %s", path))
-		return []os.DirEntry{}
-	}
-}
-
-func IsHidden(file string) (bool, error) {
-	return strings.HasPrefix(file, "."), nil
-}
-
-func isExtSupported(path string) bool {
-	ext := strings.ToLower(filepath.Ext(path))
-	return (ext == ".jpg" || ext == ".jpeg" || ext == ".webp" || ext == ".png")
-}

+ 0 - 20
packages/tui/internal/components/dialog/help.go

@@ -50,26 +50,6 @@ func (h *helpDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return h, nil
 }
 
-// func removeDuplicateBindings(bindings []key.Binding) []key.Binding {
-// 	seen := make(map[string]struct{})
-// 	result := make([]key.Binding, 0, len(bindings))
-//
-// 	// Process bindings in reverse order
-// 	for i := len(bindings) - 1; i >= 0; i-- {
-// 		b := bindings[i]
-// 		k := strings.Join(b.Keys(), " ")
-// 		if _, ok := seen[k]; ok {
-// 			// duplicate, skip
-// 			continue
-// 		}
-// 		seen[k] = struct{}{}
-// 		// Add to the beginning of result to maintain original order
-// 		result = append([]key.Binding{b}, result...)
-// 	}
-//
-// 	return result
-// }
-
 func (h *helpDialog) View() string {
 	t := theme.CurrentTheme()
 	keyStyle := lipgloss.NewStyle().

+ 0 - 5
packages/tui/internal/components/dialog/init.go

@@ -173,11 +173,6 @@ func (m *InitDialogCmp) SetSize(width, height int) {
 	m.height = height
 }
 
-// Bindings implements layout.Bindings.
-func (m InitDialogCmp) Bindings() []key.Binding {
-	return m.keys.ShortHelp()
-}
-
 // CloseInitDialogMsg is a message that is sent when the init dialog is closed.
 type CloseInitDialogMsg struct {
 	Initialize bool

+ 0 - 5
packages/tui/internal/components/dialog/permission.go

@@ -31,7 +31,6 @@ type PermissionResponseMsg struct {
 // PermissionDialogComponent interface for permission dialog component
 type PermissionDialogComponent interface {
 	layout.ModelWithView
-	layout.Bindings
 	// SetPermissions(permission permission.PermissionRequest) tea.Cmd
 }
 
@@ -424,10 +423,6 @@ func (p *permissionDialogComponent) View() string {
 	return p.render()
 }
 
-func (p *permissionDialogComponent) BindingKeys() []key.Binding {
-	return layout.KeyMapToSlice(permissionsKeys)
-}
-
 func (p *permissionDialogComponent) SetSize() tea.Cmd {
 	// if p.permission.ID == "" {
 	// 	return nil

+ 3 - 3
packages/tui/internal/components/dialog/session.go

@@ -48,7 +48,7 @@ type sessionDialog struct {
 	height            int
 	modal             *modal.Modal
 	selectedSessionID string
-	list              components.SimpleList[sessionItem]
+	list              components.List[sessionItem]
 }
 
 func (s *sessionDialog) Init() tea.Cmd {
@@ -77,7 +77,7 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 	var cmd tea.Cmd
 	listModel, cmd := s.list.Update(msg)
-	s.list = listModel.(components.SimpleList[sessionItem])
+	s.list = listModel.(components.List[sessionItem])
 	return s, cmd
 }
 
@@ -98,7 +98,7 @@ func NewSessionDialog(app *app.App) SessionDialog {
 		sessionItems = append(sessionItems, sessionItem{session: sess})
 	}
 
-	list := components.NewSimpleList(
+	list := components.NewListComponent(
 		sessionItems,
 		10, // maxVisibleSessions
 		"No sessions available",

+ 3 - 3
packages/tui/internal/components/dialog/theme.go

@@ -49,7 +49,7 @@ type themeDialog struct {
 	height int
 
 	modal *modal.Modal
-	list  components.SimpleList[themeItem]
+	list  components.List[themeItem]
 }
 
 func (t *themeDialog) Init() tea.Cmd {
@@ -84,7 +84,7 @@ func (t *themeDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 	var cmd tea.Cmd
 	listModel, cmd := t.list.Update(msg)
-	t.list = listModel.(components.SimpleList[themeItem])
+	t.list = listModel.(components.List[themeItem])
 	return t, cmd
 }
 
@@ -110,7 +110,7 @@ func NewThemeDialog() ThemeDialog {
 		}
 	}
 
-	list := components.NewSimpleList(
+	list := components.NewListComponent(
 		themeItems,
 		10, // maxVisibleThemes
 		"No themes available",

+ 20 - 20
packages/tui/internal/components/util/simple-list.go

@@ -8,21 +8,21 @@ import (
 	"github.com/sst/opencode/internal/styles"
 )
 
-type SimpleListItem interface {
+type ListItem interface {
 	Render(selected bool, width int) string
 }
 
-type SimpleList[T SimpleListItem] interface {
+type List[T ListItem] interface {
 	layout.ModelWithView
-	layout.Bindings
 	SetMaxWidth(maxWidth int)
 	GetSelectedItem() (item T, idx int)
 	SetItems(items []T)
 	GetItems() []T
 	SetSelectedIndex(idx int)
+	IsEmpty() bool
 }
 
-type simpleListComponent[T SimpleListItem] struct {
+type listComponent[T ListItem] struct {
 	fallbackMsg         string
 	items               []T
 	selectedIdx         int
@@ -33,14 +33,14 @@ type simpleListComponent[T SimpleListItem] struct {
 	height              int
 }
 
-type simpleListKeyMap struct {
+type listKeyMap struct {
 	Up        key.Binding
 	Down      key.Binding
 	UpAlpha   key.Binding
 	DownAlpha key.Binding
 }
 
-var simpleListKeys = simpleListKeyMap{
+var simpleListKeys = listKeyMap{
 	Up: key.NewBinding(
 		key.WithKeys("up"),
 		key.WithHelp("↑", "previous list item"),
@@ -59,11 +59,11 @@ var simpleListKeys = simpleListKeyMap{
 	),
 }
 
-func (c *simpleListComponent[T]) Init() tea.Cmd {
+func (c *listComponent[T]) Init() tea.Cmd {
 	return nil
 }
 
-func (c *simpleListComponent[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (c *listComponent[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.KeyMsg:
 		switch {
@@ -83,11 +83,7 @@ func (c *simpleListComponent[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return c, nil
 }
 
-func (c *simpleListComponent[T]) BindingKeys() []key.Binding {
-	return layout.KeyMapToSlice(simpleListKeys)
-}
-
-func (c *simpleListComponent[T]) GetSelectedItem() (T, int) {
+func (c *listComponent[T]) GetSelectedItem() (T, int) {
 	if len(c.items) > 0 {
 		return c.items[c.selectedIdx], c.selectedIdx
 	}
@@ -96,26 +92,30 @@ func (c *simpleListComponent[T]) GetSelectedItem() (T, int) {
 	return zero, -1
 }
 
-func (c *simpleListComponent[T]) SetItems(items []T) {
+func (c *listComponent[T]) SetItems(items []T) {
 	c.selectedIdx = 0
 	c.items = items
 }
 
-func (c *simpleListComponent[T]) GetItems() []T {
+func (c *listComponent[T]) GetItems() []T {
 	return c.items
 }
 
-func (c *simpleListComponent[T]) SetMaxWidth(width int) {
+func (c *listComponent[T]) IsEmpty() bool {
+	return len(c.items) == 0
+}
+
+func (c *listComponent[T]) SetMaxWidth(width int) {
 	c.maxWidth = width
 }
 
-func (c *simpleListComponent[T]) SetSelectedIndex(idx int) {
+func (c *listComponent[T]) SetSelectedIndex(idx int) {
 	if idx >= 0 && idx < len(c.items) {
 		c.selectedIdx = idx
 	}
 }
 
-func (c *simpleListComponent[T]) View() string {
+func (c *listComponent[T]) View() string {
 	baseStyle := styles.BaseStyle()
 
 	items := c.items
@@ -152,8 +152,8 @@ func (c *simpleListComponent[T]) View() string {
 	return lipgloss.JoinVertical(lipgloss.Left, listItems...)
 }
 
-func NewSimpleList[T SimpleListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) SimpleList[T] {
-	return &simpleListComponent[T]{
+func NewListComponent[T ListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) List[T] {
+	return &listComponent[T]{
 		fallbackMsg:         fallbackMsg,
 		items:               items,
 		maxVisibleItems:     maxVisibleItems,

+ 0 - 9
packages/tui/internal/layout/container.go

@@ -1,7 +1,6 @@
 package layout
 
 import (
-	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/sst/opencode/internal/theme"
@@ -15,7 +14,6 @@ type ModelWithView interface {
 type Container interface {
 	ModelWithView
 	Sizeable
-	Bindings
 	Focus()
 	Blur()
 	MaxWidth() int
@@ -153,13 +151,6 @@ func (c *container) Alignment() lipgloss.Position {
 	return c.align
 }
 
-func (c *container) BindingKeys() []key.Binding {
-	if b, ok := c.content.(Bindings); ok {
-		return b.BindingKeys()
-	}
-	return []key.Binding{}
-}
-
 // Focus sets the container as focused
 func (c *container) Focus() {
 	c.focused = true

+ 0 - 14
packages/tui/internal/layout/flex.go

@@ -1,7 +1,6 @@
 package layout
 
 import (
-	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/lipgloss/v2"
 )
@@ -27,7 +26,6 @@ func FlexPaneSizeFixed(size int) FlexPaneSize {
 type FlexLayout interface {
 	ModelWithView
 	Sizeable
-	Bindings
 	SetPanes(panes []Container) tea.Cmd
 	SetPaneSizes(sizes []FlexPaneSize) tea.Cmd
 	SetDirection(direction FlexDirection) tea.Cmd
@@ -199,18 +197,6 @@ func (f *flexLayout) SetDirection(direction FlexDirection) tea.Cmd {
 	return nil
 }
 
-func (f *flexLayout) BindingKeys() []key.Binding {
-	keys := []key.Binding{}
-	for _, pane := range f.panes {
-		if pane != nil {
-			if b, ok := pane.(Bindings); ok {
-				keys = append(keys, b.BindingKeys()...)
-			}
-		}
-	}
-	return keys
-}
-
 func NewFlexLayout(options ...FlexLayoutOption) FlexLayout {
 	layout := &flexLayout{
 		direction: FlexDirectionHorizontal,

+ 0 - 4
packages/tui/internal/layout/layout.go

@@ -45,10 +45,6 @@ type Sizeable interface {
 	GetSize() (int, int)
 }
 
-type Bindings interface {
-	BindingKeys() []key.Binding
-}
-
 func KeyMapToSlice(t any) (bindings []key.Binding) {
 	typ := reflect.TypeOf(t)
 	if typ.Kind() != reflect.Struct {

+ 3 - 10
packages/tui/internal/page/chat.go

@@ -61,13 +61,13 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		cmd := p.layout.SetSize(msg.Width, msg.Height)
 		cmds = append(cmds, cmd)
 	case chat.SendMsg:
+		p.showCompletionDialog = false
 		cmd := p.sendMessage(msg.Text, msg.Attachments)
 		if cmd != nil {
 			return p, cmd
 		}
 	case dialog.CompletionDialogCloseMsg:
 		p.showCompletionDialog = false
-		p.app.SetCompletionDialogOpen(false)
 	case tea.KeyMsg:
 		switch msg.String() {
 		case "ctrl+c":
@@ -80,7 +80,6 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		switch {
 		case key.Matches(msg, keyMap.ShowCompletionDialog):
 			p.showCompletionDialog = true
-			p.app.SetCompletionDialogOpen(true)
 			// Continue sending keys to layout->chat
 		case key.Matches(msg, keyMap.Cancel):
 			if p.app.Session.Id != "" {
@@ -93,6 +92,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return p, util.CmdHandler(chat.ToggleToolMessagesMsg{})
 		}
 	}
+
 	if p.showCompletionDialog {
 		context, contextCmd := p.completionDialog.Update(msg)
 		p.completionDialog = context.(dialog.CompletionDialog)
@@ -100,7 +100,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 		// Doesn't forward event if enter key is pressed
 		if keyMsg, ok := msg.(tea.KeyMsg); ok {
-			if keyMsg.String() == "enter" {
+			if keyMsg.String() == "enter" && !p.completionDialog.IsEmpty() {
 				return p, tea.Batch(cmds...)
 			}
 		}
@@ -149,13 +149,6 @@ func (p *chatPage) View() string {
 	return layoutView
 }
 
-func (p *chatPage) BindingKeys() []key.Binding {
-	bindings := layout.KeyMapToSlice(keyMap)
-	bindings = append(bindings, p.messages.BindingKeys()...)
-	bindings = append(bindings, p.editor.BindingKeys()...)
-	return bindings
-}
-
 func NewChatPage(app *app.App) layout.ModelWithView {
 	cg := completions.NewFileAndFolderContextGroup()
 	completionDialog := dialog.NewCompletionDialogComponent(cg)

+ 4 - 56
packages/tui/internal/tui/tui.go

@@ -23,8 +23,6 @@ import (
 	"github.com/sst/opencode/pkg/client"
 )
 
-
-
 type appModel struct {
 	width, height int
 	currentPage   page.PageID
@@ -34,7 +32,6 @@ type appModel struct {
 	status        core.StatusComponent
 	app           *app.App
 	modal         layout.Modal
-	commands      *commands.Registry
 }
 
 func (a appModel) Init() tea.Cmd {
@@ -99,7 +96,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				}
 			}
 
-			for _, cmdDef := range a.commands.Commands {
+			for _, cmdDef := range a.app.Commands {
 				if key.Matches(msg, cmdDef.KeyBinding) {
 					isModalTrigger = true
 					break
@@ -136,7 +133,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			a.modal = themeDialog
 		case "help":
 			var helpBindings []key.Binding
-			for _, cmd := range a.commands.Commands {
+			for _, cmd := range a.app.Commands {
 				// Create a new binding for help display
 				helpBindings = append(helpBindings, key.NewBinding(
 					key.WithKeys(cmd.KeyBinding.Keys()...),
@@ -268,6 +265,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 	case tea.KeyMsg:
 		switch msg.String() {
+		// give the editor a chance to clear input
 		case "ctrl+c":
 			updated, cmd := a.pages[a.currentPage].Update(msg)
 			a.pages[a.currentPage] = updated.(layout.ModelWithView)
@@ -278,7 +276,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 		// First, check for modal triggers from the command registry
 		if a.modal == nil {
-			for _, cmdDef := range a.commands.Commands {
+			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.
@@ -331,55 +329,6 @@ func (a appModel) View() string {
 	return appView
 }
 
-func newCommandRegistry() *commands.Registry {
-	return &commands.Registry{
-		Commands: map[string]commands.Command{
-			"help": {
-				Name:        "help",
-				Description: "show help",
-				KeyBinding: key.NewBinding(
-					key.WithKeys("f1", "super+/", "super+h"),
-				),
-			},
-			"new": {
-				Name:        "new",
-				Description: "new session",
-				KeyBinding: key.NewBinding(
-					key.WithKeys("f2", "super+n"),
-				),
-			},
-			"sessions": {
-				Name:        "sessions",
-				Description: "switch session",
-				KeyBinding: key.NewBinding(
-					key.WithKeys("f3", "super+s"),
-				),
-			},
-			"model": {
-				Name:        "model",
-				Description: "switch model",
-				KeyBinding: key.NewBinding(
-					key.WithKeys("f4", "super+m"),
-				),
-			},
-			"theme": {
-				Name:        "theme",
-				Description: "switch theme",
-				KeyBinding: key.NewBinding(
-					key.WithKeys("f5", "super+t"),
-				),
-			},
-			"quit": {
-				Name:        "quit",
-				Description: "quit",
-				KeyBinding: key.NewBinding(
-					key.WithKeys("f10", "ctrl+c", "super+q"),
-				),
-			},
-		},
-	}
-}
-
 func NewModel(app *app.App) tea.Model {
 	startPage := page.ChatPage
 	model := &appModel{
@@ -387,7 +336,6 @@ func NewModel(app *app.App) tea.Model {
 		loadedPages: make(map[page.PageID]bool),
 		status:      core.NewStatusCmp(app),
 		app:         app,
-		commands:    newCommandRegistry(),
 		pages: map[page.PageID]layout.ModelWithView{
 			page.ChatPage: page.NewChatPage(app),
 		},