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

feat(tui): file viewer, select messages

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

+ 1 - 1
packages/opencode/src/file/ripgrep.ts

@@ -32,7 +32,7 @@ export namespace Ripgrep {
     }),
   })
 
-  const Match = z.object({
+  export const Match = z.object({
     type: z.literal("match"),
     data: z.object({
       path: z.object({

+ 130 - 4
packages/opencode/src/server/server.ts

@@ -14,6 +14,8 @@ import { NamedError } from "../util/error"
 import { ModelsDev } from "../provider/models"
 import { Ripgrep } from "../file/ripgrep"
 import { Config } from "../config/config"
+import { File } from "../file"
+import { LSP } from "../lsp"
 
 const ERRORS = {
   400: {
@@ -73,7 +75,7 @@ export namespace Server {
           documentation: {
             info: {
               title: "opencode",
-              version: "0.0.2",
+              version: "0.0.3",
               description: "opencode api",
             },
             openapi: "3.0.0",
@@ -492,12 +494,44 @@ export namespace Server {
         },
       )
       .get(
-        "/file",
+        "/find",
+        describeRoute({
+          description: "Find text in files",
+          responses: {
+            200: {
+              description: "Matches",
+              content: {
+                "application/json": {
+                  schema: resolver(Ripgrep.Match.shape.data.array()),
+                },
+              },
+            },
+          },
+        }),
+        zValidator(
+          "query",
+          z.object({
+            pattern: z.string(),
+          }),
+        ),
+        async (c) => {
+          const app = App.info()
+          const pattern = c.req.valid("query").pattern
+          const result = await Ripgrep.search({
+            cwd: app.path.cwd,
+            pattern,
+            limit: 10,
+          })
+          return c.json(result)
+        },
+      )
+      .get(
+        "/find/file",
         describeRoute({
-          description: "Search for files",
+          description: "Find files",
           responses: {
             200: {
-              description: "Search for files",
+              description: "File paths",
               content: {
                 "application/json": {
                   schema: resolver(z.string().array()),
@@ -523,6 +557,98 @@ export namespace Server {
           return c.json(result)
         },
       )
+      .get(
+        "/find/symbol",
+        describeRoute({
+          description: "Find workspace symbols",
+          responses: {
+            200: {
+              description: "Symbols",
+              content: {
+                "application/json": {
+                  schema: resolver(z.unknown().array()),
+                },
+              },
+            },
+          },
+        }),
+        zValidator(
+          "query",
+          z.object({
+            query: z.string(),
+          }),
+        ),
+        async (c) => {
+          const query = c.req.valid("query").query
+          const result = await LSP.workspaceSymbol(query)
+          return c.json(result)
+        },
+      )
+      .get(
+        "/file",
+        describeRoute({
+          description: "Read a file",
+          responses: {
+            200: {
+              description: "File content",
+              content: {
+                "application/json": {
+                  schema: resolver(
+                    z.object({
+                      type: z.enum(["raw", "patch"]),
+                      content: z.string(),
+                    }),
+                  ),
+                },
+              },
+            },
+          },
+        }),
+        zValidator(
+          "query",
+          z.object({
+            path: z.string(),
+          }),
+        ),
+        async (c) => {
+          const path = c.req.valid("query").path
+          const content = await File.read(path)
+          log.info("read file", {
+            path,
+            content: content.content,
+          })
+          return c.json(content)
+        },
+      )
+      .get(
+        "/file/status",
+        describeRoute({
+          description: "Get file status",
+          responses: {
+            200: {
+              description: "File status",
+              content: {
+                "application/json": {
+                  schema: resolver(
+                    z
+                      .object({
+                        file: z.string(),
+                        added: z.number().int(),
+                        removed: z.number().int(),
+                        status: z.enum(["added", "deleted", "modified"]),
+                      })
+                      .array(),
+                  ),
+                },
+              },
+            },
+          },
+        }),
+        async (c) => {
+          const content = await File.status()
+          return c.json(content)
+        },
+      )
 
     return result
   }

+ 1 - 1
packages/tui/go.mod

@@ -15,7 +15,7 @@ require (
 	github.com/muesli/reflow v0.3.0
 	github.com/muesli/termenv v0.16.0
 	github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
-	github.com/sst/opencode-sdk-go v0.1.0-alpha.7
+	github.com/sst/opencode-sdk-go v0.1.0-alpha.8
 	github.com/tidwall/gjson v1.14.4
 	rsc.io/qr v0.2.0
 )

+ 2 - 2
packages/tui/go.sum

@@ -181,8 +181,8 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
 github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
 github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
 github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/sst/opencode-sdk-go v0.1.0-alpha.7 h1:trfzTMn9o/h2fxE4z+BtJPZvCTdVHjwgXnAH/rTAx0I=
-github.com/sst/opencode-sdk-go v0.1.0-alpha.7/go.mod h1:uagorfAHZsVy6vf0xY6TlQraM4uCILdZ5tKKhl1oToM=
+github.com/sst/opencode-sdk-go v0.1.0-alpha.8 h1:Tp7nbckbMCwAA/ieVZeeZCp79xXtrPMaWLRk5mhNwrw=
+github.com/sst/opencode-sdk-go v0.1.0-alpha.8/go.mod h1:uagorfAHZsVy6vf0xY6TlQraM4uCILdZ5tKKhl1oToM=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=

+ 19 - 5
packages/tui/internal/app/app.go

@@ -20,9 +20,6 @@ import (
 	"github.com/sst/opencode/internal/util"
 )
 
-var RootPath string
-var CwdPath string
-
 type App struct {
 	Info      opencode.App
 	Version   string
@@ -38,6 +35,7 @@ type App struct {
 }
 
 type SessionSelectedMsg = *opencode.Session
+type SessionLoadedMsg struct{}
 type ModelSelectedMsg struct {
 	Provider opencode.Provider
 	Model    opencode.Model
@@ -54,6 +52,9 @@ type CompletionDialogTriggeredMsg struct {
 type OptimisticMessageAddedMsg struct {
 	Message opencode.Message
 }
+type FileRenderedMsg struct {
+	FilePath string
+}
 
 func New(
 	ctx context.Context,
@@ -61,8 +62,8 @@ func New(
 	appInfo opencode.App,
 	httpClient *opencode.Client,
 ) (*App, error) {
-	RootPath = appInfo.Path.Root
-	CwdPath = appInfo.Path.Cwd
+	util.RootPath = appInfo.Path.Root
+	util.CwdPath = appInfo.Path.Cwd
 
 	configInfo, err := httpClient.Config.Get(ctx)
 	if err != nil {
@@ -125,6 +126,19 @@ func New(
 	return app, nil
 }
 
+func (a *App) Key(commandName commands.CommandName) string {
+	t := theme.CurrentTheme()
+	base := styles.NewStyle().Background(t.Background()).Foreground(t.Text()).Bold(true).Render
+	muted := styles.NewStyle().Background(t.Background()).Foreground(t.TextMuted()).Faint(true).Render
+	command := a.Commands[commandName]
+	kb := command.Keybindings[0]
+	key := kb.Key
+	if kb.RequiresLeader {
+		key = a.Config.Keybinds.Leader + " " + kb.Key
+	}
+	return base(key) + muted(" "+command.Description)
+}
+
 func (a *App) InitializeProvider() tea.Cmd {
 	return func() tea.Msg {
 		providersResponse, err := a.Client.Config.Providers(context.Background())

+ 45 - 14
packages/tui/internal/commands/command.go

@@ -80,13 +80,15 @@ const (
 	ToolDetailsCommand          CommandName = "tool_details"
 	ModelListCommand            CommandName = "model_list"
 	ThemeListCommand            CommandName = "theme_list"
+	FileListCommand             CommandName = "file_list"
+	FileCloseCommand            CommandName = "file_close"
+	FileSearchCommand           CommandName = "file_search"
+	FileDiffToggleCommand       CommandName = "file_diff_toggle"
 	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"
@@ -95,6 +97,9 @@ const (
 	MessagesNextCommand         CommandName = "messages_next"
 	MessagesFirstCommand        CommandName = "messages_first"
 	MessagesLastCommand         CommandName = "messages_last"
+	MessagesLayoutToggleCommand CommandName = "messages_layout_toggle"
+	MessagesCopyCommand         CommandName = "messages_copy"
+	MessagesRevertCommand       CommandName = "messages_revert"
 	AppExitCommand              CommandName = "app_exit"
 )
 
@@ -184,6 +189,27 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
 			Keybindings: parseBindings("<leader>t"),
 			Trigger:     "themes",
 		},
+		{
+			Name:        FileListCommand,
+			Description: "list files",
+			Keybindings: parseBindings("<leader>f"),
+			Trigger:     "files",
+		},
+		{
+			Name:        FileCloseCommand,
+			Description: "close file",
+			Keybindings: parseBindings("esc"),
+		},
+		{
+			Name:        FileSearchCommand,
+			Description: "search file",
+			Keybindings: parseBindings("<leader>/"),
+		},
+		{
+			Name:        FileDiffToggleCommand,
+			Description: "split/unified diff",
+			Keybindings: parseBindings("<leader>v"),
+		},
 		{
 			Name:        ProjectInitCommand,
 			Description: "create/update AGENTS.md",
@@ -210,16 +236,6 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
 			Description: "insert newline",
 			Keybindings: parseBindings("shift+enter", "ctrl+j"),
 		},
-		// {
-		// 	Name:        HistoryPreviousCommand,
-		// 	Description: "previous prompt",
-		// 	Keybindings: parseBindings("up"),
-		// },
-		// {
-		// 	Name:        HistoryNextCommand,
-		// 	Description: "next prompt",
-		// 	Keybindings: parseBindings("down"),
-		// },
 		{
 			Name:        MessagesPageUpCommand,
 			Description: "page up",
@@ -243,12 +259,12 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
 		{
 			Name:        MessagesPreviousCommand,
 			Description: "previous message",
-			Keybindings: parseBindings("ctrl+alt+k"),
+			Keybindings: parseBindings("ctrl+up"),
 		},
 		{
 			Name:        MessagesNextCommand,
 			Description: "next message",
-			Keybindings: parseBindings("ctrl+alt+j"),
+			Keybindings: parseBindings("ctrl+down"),
 		},
 		{
 			Name:        MessagesFirstCommand,
@@ -260,6 +276,21 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
 			Description: "last message",
 			Keybindings: parseBindings("ctrl+alt+g"),
 		},
+		{
+			Name:        MessagesLayoutToggleCommand,
+			Description: "toggle layout",
+			Keybindings: parseBindings("<leader>m"),
+		},
+		{
+			Name:        MessagesCopyCommand,
+			Description: "copy message",
+			Keybindings: parseBindings("<leader>y"),
+		},
+		{
+			Name:        MessagesRevertCommand,
+			Description: "revert message",
+			Keybindings: parseBindings("<leader>u"),
+		},
 		{
 			Name:        AppExitCommand,
 			Description: "exit the app",

+ 0 - 7
packages/tui/internal/completions/commands.go

@@ -25,13 +25,6 @@ func (c *CommandCompletionProvider) GetId() string {
 	return "commands"
 }
 
-func (c *CommandCompletionProvider) GetEntry() dialog.CompletionItemI {
-	return dialog.NewCompletionItem(dialog.CompletionItem{
-		Title: "Commands",
-		Value: "commands",
-	})
-}
-
 func (c *CommandCompletionProvider) GetEmptyMessage() string {
 	return "no matching commands"
 }

+ 71 - 27
packages/tui/internal/completions/files-folders.go

@@ -2,64 +2,108 @@ package completions
 
 import (
 	"context"
+	"log/slog"
+	"sort"
+	"strconv"
+	"strings"
 
 	"github.com/sst/opencode-sdk-go"
 	"github.com/sst/opencode/internal/app"
 	"github.com/sst/opencode/internal/components/dialog"
+	"github.com/sst/opencode/internal/styles"
+	"github.com/sst/opencode/internal/theme"
 )
 
 type filesAndFoldersContextGroup struct {
-	app    *app.App
-	prefix string
+	app      *app.App
+	prefix   string
+	gitFiles []dialog.CompletionItemI
 }
 
 func (cg *filesAndFoldersContextGroup) GetId() string {
 	return cg.prefix
 }
 
-func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI {
-	return dialog.NewCompletionItem(dialog.CompletionItem{
-		Title: "Files & Folders",
-		Value: "files",
-	})
-}
-
 func (cg *filesAndFoldersContextGroup) GetEmptyMessage() string {
 	return "no matching files"
 }
 
-func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) {
-	files, err := cg.app.Client.File.Search(
-		context.Background(),
-		opencode.FileSearchParams{Query: opencode.F(query)},
-	)
-	if err != nil {
-		return []string{}, err
+func (cg *filesAndFoldersContextGroup) getGitFiles() []dialog.CompletionItemI {
+	t := theme.CurrentTheme()
+	items := make([]dialog.CompletionItemI, 0)
+	base := styles.NewStyle().Background(t.BackgroundElement())
+	green := base.Foreground(t.Success()).Render
+	red := base.Foreground(t.Error()).Render
+
+	status, _ := cg.app.Client.File.Status(context.Background())
+	if status != nil {
+		files := *status
+		sort.Slice(files, func(i, j int) bool {
+			return files[i].Added+files[i].Removed > files[j].Added+files[j].Removed
+		})
+
+		for _, file := range files {
+			title := file.File
+			if file.Added > 0 {
+				title += green(" +" + strconv.Itoa(int(file.Added)))
+			}
+			if file.Removed > 0 {
+				title += red(" -" + strconv.Itoa(int(file.Removed)))
+			}
+			item := dialog.NewCompletionItem(dialog.CompletionItem{
+				Title: title,
+				Value: file.File,
+			})
+			items = append(items, item)
+		}
 	}
-	return *files, nil
+
+	return items
 }
 
 func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
-	matches, err := cg.getFiles(query)
+	items := make([]dialog.CompletionItemI, 0)
+
+	query = strings.TrimSpace(query)
+	if query == "" {
+		items = append(items, cg.gitFiles...)
+	}
+
+	files, err := cg.app.Client.Find.Files(
+		context.Background(),
+		opencode.FindFilesParams{Query: opencode.F(query)},
+	)
 	if err != nil {
-		return nil, err
+		slog.Error("Failed to get completion items", "error", err)
 	}
 
-	items := make([]dialog.CompletionItemI, 0, len(matches))
-	for _, file := range matches {
-		item := dialog.NewCompletionItem(dialog.CompletionItem{
-			Title: file,
-			Value: file,
-		})
-		items = append(items, item)
+	for _, file := range *files {
+		exists := false
+		for _, existing := range cg.gitFiles {
+			if existing.GetValue() == file {
+				if query != "" {
+					items = append(items, existing)
+				}
+				exists = true
+			}
+		}
+		if !exists {
+			item := dialog.NewCompletionItem(dialog.CompletionItem{
+				Title: file,
+				Value: file,
+			})
+			items = append(items, item)
+		}
 	}
 
 	return items, nil
 }
 
 func NewFileAndFolderContextGroup(app *app.App) dialog.CompletionProvider {
-	return &filesAndFoldersContextGroup{
+	cg := &filesAndFoldersContextGroup{
 		app:    app,
 		prefix: "file",
 	}
+	cg.gitFiles = cg.getGitFiles()
+	return cg
 }

+ 10 - 85
packages/tui/internal/components/chat/editor.go

@@ -13,7 +13,6 @@ import (
 	"github.com/sst/opencode/internal/components/dialog"
 	"github.com/sst/opencode/internal/components/textarea"
 	"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"
@@ -21,10 +20,8 @@ import (
 
 type EditorComponent interface {
 	tea.Model
-	// tea.ViewModel
-	SetSize(width, height int) tea.Cmd
-	View(width int, align lipgloss.Position) string
-	Content(width int, align lipgloss.Position) string
+	View(width int) string
+	Content(width int) string
 	Lines() int
 	Value() string
 	Focused() bool
@@ -34,19 +31,13 @@ type EditorComponent interface {
 	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)
 	SetInterruptKeyInDebounce(inDebounce bool)
 }
 
 type editorComponent struct {
 	app                    *app.App
-	width, height          int
 	textarea               textarea.Model
 	attachments            []app.Attachment
-	history                []string
-	historyIndex           int
-	currentMessage         string
 	spinner                spinner.Model
 	interruptKeyInDebounce bool
 }
@@ -106,7 +97,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, tea.Batch(cmds...)
 }
 
-func (m *editorComponent) Content(width int, align lipgloss.Position) string {
+func (m *editorComponent) Content(width int) string {
 	t := theme.CurrentTheme()
 	base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
 	muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
@@ -115,6 +106,7 @@ func (m *editorComponent) Content(width int, align lipgloss.Position) string {
 		Bold(true)
 	prompt := promptStyle.Render(">")
 
+	m.textarea.SetWidth(width - 6)
 	textarea := lipgloss.JoinHorizontal(
 		lipgloss.Top,
 		prompt,
@@ -147,7 +139,7 @@ func (m *editorComponent) Content(width int, align lipgloss.Position) string {
 		model = muted(m.app.Provider.Name) + base(" "+m.app.Model.Name)
 	}
 
-	space := m.width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
+	space := width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
 	spacer := styles.NewStyle().Background(t.Background()).Width(space).Render("")
 
 	info := hint + spacer + model
@@ -157,19 +149,18 @@ func (m *editorComponent) Content(width int, align lipgloss.Position) string {
 	return content
 }
 
-func (m *editorComponent) View(width int, align lipgloss.Position) string {
+func (m *editorComponent) View(width int) string {
 	if m.Lines() > 1 {
-		t := theme.CurrentTheme()
 		return lipgloss.Place(
 			width,
-			m.height,
-			align,
+			5,
+			lipgloss.Center,
 			lipgloss.Center,
 			"",
-			styles.WhitespaceStyle(t.Background()),
+			styles.WhitespaceStyle(theme.CurrentTheme().Background()),
 		)
 	}
-	return m.Content(width, align)
+	return m.Content(width)
 }
 
 func (m *editorComponent) Focused() bool {
@@ -184,16 +175,6 @@ func (m *editorComponent) Blur() {
 	m.textarea.Blur()
 }
 
-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
-	return nil
-}
-
 func (m *editorComponent) Lines() int {
 	return m.textarea.LineCount()
 }
@@ -219,16 +200,6 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
 	cmds = append(cmds, cmd)
 
 	attachments := m.attachments
-
-	// Save to history if not empty and not a duplicate of the last entry
-	if value != "" {
-		if len(m.history) == 0 || m.history[len(m.history)-1] != value {
-			m.history = append(m.history, value)
-		}
-		m.historyIndex = len(m.history)
-		m.currentMessage = ""
-	}
-
 	m.attachments = nil
 
 	cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: attachments}))
@@ -261,48 +232,6 @@ func (m *editorComponent) Newline() (tea.Model, tea.Cmd) {
 	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 (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
 	m.interruptKeyInDebounce = inDebounce
 }
@@ -336,7 +265,6 @@ func createTextArea(existing *textarea.Model) textarea.Model {
 	ta.Prompt = " "
 	ta.ShowLineNumbers = false
 	ta.CharLimit = -1
-	ta.SetWidth(layout.Current.Container.Width - 6)
 
 	if existing != nil {
 		ta.SetValue(existing.Value())
@@ -368,9 +296,6 @@ func NewEditorComponent(app *app.App) EditorComponent {
 	return &editorComponent{
 		app:                    app,
 		textarea:               ta,
-		history:                []string{},
-		historyIndex:           0,
-		currentMessage:         "",
 		spinner:                s,
 		interruptKeyInDebounce: false,
 	}

+ 121 - 158
packages/tui/internal/components/chat/message.go

@@ -3,65 +3,46 @@ package chat
 import (
 	"encoding/json"
 	"fmt"
-	"path/filepath"
 	"slices"
 	"strings"
 	"time"
-	"unicode"
 
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/charmbracelet/lipgloss/v2/compat"
-	"github.com/charmbracelet/x/ansi"
 	"github.com/sst/opencode-sdk-go"
 	"github.com/sst/opencode/internal/app"
+	"github.com/sst/opencode/internal/commands"
 	"github.com/sst/opencode/internal/components/diff"
 	"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/tidwall/gjson"
 	"golang.org/x/text/cases"
 	"golang.org/x/text/language"
 )
 
-func toMarkdown(content string, width int, backgroundColor compat.AdaptiveColor) string {
-	r := styles.GetMarkdownRenderer(width-7, backgroundColor)
-	content = strings.ReplaceAll(content, app.RootPath+"/", "")
-	rendered, _ := r.Render(content)
-	lines := strings.Split(rendered, "\n")
-
-	if len(lines) > 0 {
-		firstLine := lines[0]
-		cleaned := ansi.Strip(firstLine)
-		nospace := strings.ReplaceAll(cleaned, " ", "")
-		if nospace == "" {
-			lines = lines[1:]
-		}
-		if len(lines) > 0 {
-			lastLine := lines[len(lines)-1]
-			cleaned = ansi.Strip(lastLine)
-			nospace = strings.ReplaceAll(cleaned, " ", "")
-			if nospace == "" {
-				lines = lines[:len(lines)-1]
-			}
-		}
-	}
-	content = strings.Join(lines, "\n")
-	return strings.TrimSuffix(content, "\n")
-}
-
 type blockRenderer struct {
-	border        bool
-	borderColor   *compat.AdaptiveColor
-	paddingTop    int
-	paddingBottom int
-	paddingLeft   int
-	paddingRight  int
-	marginTop     int
-	marginBottom  int
+	textColor        compat.AdaptiveColor
+	border           bool
+	borderColor      *compat.AdaptiveColor
+	borderColorRight bool
+	paddingTop       int
+	paddingBottom    int
+	paddingLeft      int
+	paddingRight     int
+	marginTop        int
+	marginBottom     int
 }
 
 type renderingOption func(*blockRenderer)
 
+func WithTextColor(color compat.AdaptiveColor) renderingOption {
+	return func(c *blockRenderer) {
+		c.textColor = color
+	}
+}
+
 func WithNoBorder() renderingOption {
 	return func(c *blockRenderer) {
 		c.border = false
@@ -74,6 +55,13 @@ func WithBorderColor(color compat.AdaptiveColor) renderingOption {
 	}
 }
 
+func WithBorderColorRight(color compat.AdaptiveColor) renderingOption {
+	return func(c *blockRenderer) {
+		c.borderColorRight = true
+		c.borderColor = &color
+	}
+}
+
 func WithMarginTop(padding int) renderingOption {
 	return func(c *blockRenderer) {
 		c.marginTop = padding
@@ -120,13 +108,15 @@ func WithPaddingBottom(padding int) renderingOption {
 }
 
 func renderContentBlock(
+	app *app.App,
 	content string,
+	highlight bool,
 	width int,
-	align lipgloss.Position,
 	options ...renderingOption,
 ) string {
 	t := theme.CurrentTheme()
 	renderer := &blockRenderer{
+		textColor:     t.TextMuted(),
 		border:        true,
 		paddingTop:    1,
 		paddingBottom: 1,
@@ -143,7 +133,7 @@ func renderContentBlock(
 	}
 
 	style := styles.NewStyle().
-		Foreground(t.TextMuted()).
+		Foreground(renderer.textColor).
 		Background(t.BackgroundPanel()).
 		Width(width).
 		PaddingTop(renderer.paddingTop).
@@ -161,21 +151,32 @@ func renderContentBlock(
 			BorderLeftBackground(t.Background()).
 			BorderRightForeground(t.BackgroundPanel()).
 			BorderRightBackground(t.Background())
+
+		if renderer.borderColorRight {
+			style = style.
+				BorderLeftBackground(t.Background()).
+				BorderLeftForeground(t.BackgroundPanel()).
+				BorderRightForeground(borderColor).
+				BorderRightBackground(t.Background())
+		}
+
+		if highlight {
+			style = style.
+				BorderLeftBackground(t.Primary()).
+				BorderLeftForeground(t.Primary()).
+				BorderRightForeground(t.Primary()).
+				BorderRightBackground(t.Primary())
+		}
+	}
+
+	if highlight {
+		style = style.
+			Foreground(t.Text()).
+			Bold(true).
+			Background(t.BackgroundElement())
 	}
 
 	content = style.Render(content)
-	content = lipgloss.PlaceHorizontal(
-		width,
-		lipgloss.Left,
-		content,
-		styles.WhitespaceStyle(t.Background()),
-	)
-	content = lipgloss.PlaceHorizontal(
-		layout.Current.Viewport.Width,
-		align,
-		content,
-		styles.WhitespaceStyle(t.Background()),
-	)
 	if renderer.marginTop > 0 {
 		for range renderer.marginTop {
 			content = "\n" + content
@@ -186,16 +187,44 @@ func renderContentBlock(
 			content = content + "\n"
 		}
 	}
+
+	if highlight {
+		copy := app.Key(commands.MessagesCopyCommand)
+		// revert := app.Key(commands.MessagesRevertCommand)
+
+		background := t.Background()
+		header := layout.Render(
+			layout.FlexOptions{
+				Background: &background,
+				Direction:  layout.Row,
+				Justify:    layout.JustifyCenter,
+				Align:      layout.AlignStretch,
+				Width:      width - 2,
+				Gap:        5,
+			},
+			layout.FlexItem{
+				View: copy,
+			},
+			// layout.FlexItem{
+			// 	View: revert,
+			// },
+		)
+		header = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(header)
+
+		content = "\n\n\n" + header + "\n\n" + content + "\n\n"
+	}
+
 	return content
 }
 
 func renderText(
+	app *app.App,
 	message opencode.Message,
 	text string,
 	author string,
 	showToolDetails bool,
+	highlight bool,
 	width int,
-	align lipgloss.Position,
 	toolCalls ...opencode.ToolInvocationPart,
 ) string {
 	t := theme.CurrentTheme()
@@ -206,17 +235,20 @@ func renderText(
 		timestamp = timestamp[12:]
 	}
 	info := fmt.Sprintf("%s (%s)", author, timestamp)
+	info = styles.NewStyle().Foreground(t.TextMuted()).Render(info)
 
-	messageStyle := styles.NewStyle().
-		Background(t.BackgroundPanel()).
-		Foreground(t.Text())
+	backgroundColor := t.BackgroundPanel()
+	if highlight {
+		backgroundColor = t.BackgroundElement()
+	}
+	messageStyle := styles.NewStyle().Background(backgroundColor)
 	if message.Role == opencode.MessageRoleUser {
 		messageStyle = messageStyle.Width(width - 6)
 	}
 
 	content := messageStyle.Render(text)
 	if message.Role == opencode.MessageRoleAssistant {
-		content = toMarkdown(text, width, t.BackgroundPanel())
+		content = util.ToMarkdown(text, width, backgroundColor)
 	}
 
 	if !showToolDetails && toolCalls != nil && len(toolCalls) > 0 {
@@ -242,16 +274,19 @@ func renderText(
 	switch message.Role {
 	case opencode.MessageRoleUser:
 		return renderContentBlock(
+			app,
 			content,
+			highlight,
 			width,
-			align,
-			WithBorderColor(t.Secondary()),
+			WithTextColor(t.Text()),
+			WithBorderColorRight(t.Secondary()),
 		)
 	case opencode.MessageRoleAssistant:
 		return renderContentBlock(
+			app,
 			content,
+			highlight,
 			width,
-			align,
 			WithBorderColor(t.Accent()),
 		)
 	}
@@ -259,10 +294,11 @@ func renderText(
 }
 
 func renderToolDetails(
+	app *app.App,
 	toolCall opencode.ToolInvocationPart,
 	messageMetadata opencode.MessageMetadata,
+	highlight bool,
 	width int,
-	align lipgloss.Position,
 ) string {
 	ignoredTools := []string{"todoread"}
 	if slices.Contains(ignoredTools, toolCall.ToolInvocation.ToolName) {
@@ -282,7 +318,7 @@ func renderToolDetails(
 
 	if toolCall.ToolInvocation.State == "partial-call" {
 		title := renderToolTitle(toolCall, messageMetadata, width)
-		return renderContentBlock(title, width, align)
+		return renderContentBlock(app, title, highlight, width)
 	}
 
 	toolArgsMap := make(map[string]any)
@@ -301,6 +337,10 @@ func renderToolDetails(
 	body := ""
 	finished := result != nil && *result != ""
 	t := theme.CurrentTheme()
+	backgroundColor := t.BackgroundPanel()
+	if highlight {
+		backgroundColor = t.BackgroundElement()
+	}
 
 	switch toolCall.ToolInvocation.ToolName {
 	case "read":
@@ -308,7 +348,7 @@ func renderToolDetails(
 		if preview != nil && toolArgsMap["filePath"] != nil {
 			filename := toolArgsMap["filePath"].(string)
 			body = preview.(string)
-			body = renderFile(filename, body, width, WithTruncate(6))
+			body = util.RenderFile(filename, body, width, util.WithTruncate(6))
 		}
 	case "edit":
 		if filename, ok := toolArgsMap["filePath"].(string); ok {
@@ -321,38 +361,28 @@ func renderToolDetails(
 					patch,
 					diff.WithWidth(width-2),
 				)
-				formattedDiff = strings.TrimSpace(formattedDiff)
-				formattedDiff = styles.NewStyle().
-					BorderStyle(lipgloss.ThickBorder()).
-					BorderBackground(t.Background()).
-					BorderForeground(t.BackgroundPanel()).
-					BorderLeft(true).
-					BorderRight(true).
-					Render(formattedDiff)
-
 				body = strings.TrimSpace(formattedDiff)
-				body = renderContentBlock(
-					body,
-					width,
-					align,
-					WithNoBorder(),
-					WithPadding(0),
-				)
+				style := styles.NewStyle().Background(backgroundColor).Foreground(t.TextMuted()).Padding(1, 2).Width(width - 4)
+				if highlight {
+					style = style.Foreground(t.Text()).Bold(true)
+				}
 
 				if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
-					body += "\n" + renderContentBlock(diagnostics, width, align)
+					diagnostics = style.Render(diagnostics)
+					body += "\n" + diagnostics
 				}
 
 				title := renderToolTitle(toolCall, messageMetadata, width)
-				title = renderContentBlock(title, width, align)
+				title = style.Render(title)
 				content := title + "\n" + body
+				content = renderContentBlock(app, content, highlight, width, WithPadding(0))
 				return content
 			}
 		}
 	case "write":
 		if filename, ok := toolArgsMap["filePath"].(string); ok {
 			if content, ok := toolArgsMap["content"].(string); ok {
-				body = renderFile(filename, content, width)
+				body = util.RenderFile(filename, content, width)
 				if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
 					body += "\n\n" + diagnostics
 				}
@@ -363,14 +393,14 @@ func renderToolDetails(
 		if stdout != nil {
 			command := toolArgsMap["command"].(string)
 			body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
-			body = toMarkdown(body, width, t.BackgroundPanel())
+			body = util.ToMarkdown(body, width, backgroundColor)
 		}
 	case "webfetch":
 		if format, ok := toolArgsMap["format"].(string); ok && result != nil {
 			body = *result
-			body = truncateHeight(body, 10)
+			body = util.TruncateHeight(body, 10)
 			if format == "html" || format == "markdown" {
-				body = toMarkdown(body, width, t.BackgroundPanel())
+				body = util.ToMarkdown(body, width, backgroundColor)
 			}
 		}
 	case "todowrite":
@@ -389,7 +419,7 @@ func renderToolDetails(
 					body += fmt.Sprintf("- [ ] %s\n", content)
 				}
 			}
-			body = toMarkdown(body, width, t.BackgroundPanel())
+			body = util.ToMarkdown(body, width, backgroundColor)
 		}
 	case "task":
 		summary := metadata.JSON.ExtraFields["summary"]
@@ -424,7 +454,7 @@ func renderToolDetails(
 			result = &empty
 		}
 		body = *result
-		body = truncateHeight(body, 10)
+		body = util.TruncateHeight(body, 10)
 	}
 
 	error := ""
@@ -437,18 +467,18 @@ func renderToolDetails(
 	if error != "" {
 		body = styles.NewStyle().
 			Foreground(t.Error()).
-			Background(t.BackgroundPanel()).
+			Background(backgroundColor).
 			Render(error)
 	}
 
 	if body == "" && error == "" && result != nil {
 		body = *result
-		body = truncateHeight(body, 10)
+		body = util.TruncateHeight(body, 10)
 	}
 
 	title := renderToolTitle(toolCall, messageMetadata, width)
 	content := title + "\n\n" + body
-	return renderContentBlock(content, width, align)
+	return renderContentBlock(app, content, highlight, width)
 }
 
 func renderToolName(name string) string {
@@ -505,7 +535,7 @@ func renderToolTitle(
 		title = fmt.Sprintf("%s %s", title, toolArgs)
 	case "edit", "write":
 		if filename, ok := toolArgsMap["filePath"].(string); ok {
-			title = fmt.Sprintf("%s %s", title, relative(filename))
+			title = fmt.Sprintf("%s %s", title, util.Relative(filename))
 		}
 	case "bash", "task":
 		if description, ok := toolArgsMap["description"].(string); ok {
@@ -551,50 +581,6 @@ func renderToolAction(name string) string {
 	return "Working..."
 }
 
-type fileRenderer struct {
-	filename string
-	content  string
-	height   int
-}
-
-type fileRenderingOption func(*fileRenderer)
-
-func WithTruncate(height int) fileRenderingOption {
-	return func(c *fileRenderer) {
-		c.height = height
-	}
-}
-
-func renderFile(
-	filename string,
-	content string,
-	width int,
-	options ...fileRenderingOption) string {
-	t := theme.CurrentTheme()
-	renderer := &fileRenderer{
-		filename: filename,
-		content:  content,
-	}
-	for _, option := range options {
-		option(renderer)
-	}
-
-	lines := []string{}
-	for line := range strings.SplitSeq(content, "\n") {
-		line = strings.TrimRightFunc(line, unicode.IsSpace)
-		line = strings.ReplaceAll(line, "\t", "  ")
-		lines = append(lines, line)
-	}
-	content = strings.Join(lines, "\n")
-
-	if renderer.height > 0 {
-		content = truncateHeight(content, renderer.height)
-	}
-	content = fmt.Sprintf("```%s\n%s\n```", extension(renderer.filename), content)
-	content = toMarkdown(content, width, t.BackgroundPanel())
-	return content
-}
-
 func renderArgs(args *map[string]any, titleKey string) string {
 	if args == nil || len(*args) == 0 {
 		return ""
@@ -614,7 +600,7 @@ func renderArgs(args *map[string]any, titleKey string) string {
 			continue
 		}
 		if key == "filePath" || key == "path" {
-			value = relative(value.(string))
+			value = util.Relative(value.(string))
 		}
 		if key == titleKey {
 			title = fmt.Sprintf("%s", value)
@@ -628,29 +614,6 @@ func renderArgs(args *map[string]any, titleKey string) string {
 	return fmt.Sprintf("%s (%s)", title, strings.Join(parts, ", "))
 }
 
-func truncateHeight(content string, height int) string {
-	lines := strings.Split(content, "\n")
-	if len(lines) > height {
-		return strings.Join(lines[:height], "\n")
-	}
-	return content
-}
-
-func relative(path string) string {
-	path = strings.TrimPrefix(path, app.CwdPath+"/")
-	return strings.TrimPrefix(path, app.RootPath+"/")
-}
-
-func extension(path string) string {
-	ext := filepath.Ext(path)
-	if ext == "" {
-		ext = ""
-	} else {
-		ext = strings.ToLower(ext[1:])
-	}
-	return ext
-}
-
 // Diagnostic represents an LSP diagnostic
 type Diagnostic struct {
 	Range struct {

+ 134 - 82
packages/tui/internal/components/chat/messages.go

@@ -9,7 +9,6 @@ import (
 	"github.com/sst/opencode-sdk-go"
 	"github.com/sst/opencode/internal/app"
 	"github.com/sst/opencode/internal/components/dialog"
-	"github.com/sst/opencode/internal/layout"
 	"github.com/sst/opencode/internal/styles"
 	"github.com/sst/opencode/internal/theme"
 	"github.com/sst/opencode/internal/util"
@@ -17,73 +16,99 @@ import (
 
 type MessagesComponent interface {
 	tea.Model
-	tea.ViewModel
-	// View(width int) string
-	SetSize(width, height int) tea.Cmd
+	View(width, height int) string
+	SetWidth(width int) tea.Cmd
 	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)
+	Previous() (tea.Model, tea.Cmd)
+	Next() (tea.Model, tea.Cmd)
 	ToolDetailsVisible() bool
+	Selected() string
 }
 
 type messagesComponent struct {
-	width, height   int
+	width           int
 	app             *app.App
 	viewport        viewport.Model
-	attachments     viewport.Model
 	cache           *MessageCache
 	rendering       bool
 	showToolDetails bool
 	tail            bool
+	partCount       int
+	lineCount       int
+	selectedPart    int
+	selectedText    string
 }
 type renderFinishedMsg struct{}
+type selectedMessagePartChangedMsg struct {
+	part int
+}
+
 type ToggleToolDetailsMsg struct{}
 
 func (m *messagesComponent) Init() tea.Cmd {
 	return tea.Batch(m.viewport.Init())
 }
 
+func (m *messagesComponent) Selected() string {
+	return m.selectedText
+}
+
 func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmds []tea.Cmd
-	switch msg.(type) {
+	switch msg := msg.(type) {
 	case app.SendMsg:
 		m.viewport.GotoBottom()
 		m.tail = true
+		m.selectedPart = -1
 		return m, nil
 	case app.OptimisticMessageAddedMsg:
-		m.renderView()
+		m.renderView(m.width)
 		if m.tail {
 			m.viewport.GotoBottom()
 		}
 		return m, nil
 	case dialog.ThemeSelectedMsg:
 		m.cache.Clear()
+		m.rendering = true
 		return m, m.Reload()
 	case ToggleToolDetailsMsg:
 		m.showToolDetails = !m.showToolDetails
+		m.rendering = true
 		return m, m.Reload()
-	case app.SessionSelectedMsg:
+	case app.SessionLoadedMsg:
 		m.cache.Clear()
 		m.tail = true
+		m.rendering = true
 		return m, m.Reload()
 	case app.SessionClearedMsg:
 		m.cache.Clear()
-		cmd := m.Reload()
-		return m, cmd
+		m.rendering = true
+		return m, m.Reload()
 	case renderFinishedMsg:
 		m.rendering = false
 		if m.tail {
 			m.viewport.GotoBottom()
 		}
-	case opencode.EventListResponseEventSessionUpdated, opencode.EventListResponseEventMessageUpdated:
-		m.renderView()
-		if m.tail {
-			m.viewport.GotoBottom()
+	case selectedMessagePartChangedMsg:
+		return m, m.Reload()
+	case opencode.EventListResponseEventSessionUpdated:
+		if msg.Properties.Info.ID == m.app.Session.ID {
+			m.renderView(m.width)
+			if m.tail {
+				m.viewport.GotoBottom()
+			}
+		}
+	case opencode.EventListResponseEventMessageUpdated:
+		if msg.Properties.Info.Metadata.SessionID == m.app.Session.ID {
+			m.renderView(m.width)
+			if m.tail {
+				m.viewport.GotoBottom()
+			}
 		}
 	}
 
@@ -95,45 +120,46 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, tea.Batch(cmds...)
 }
 
-func (m *messagesComponent) renderView() {
-	if m.width == 0 {
-		return
-	}
-
+func (m *messagesComponent) renderView(width int) {
 	measure := util.Measure("messages.renderView")
 	defer measure("messageCount", len(m.app.Messages))
 
 	t := theme.CurrentTheme()
+	blocks := make([]string, 0)
+	m.partCount = 0
+	m.lineCount = 0
 
-	align := lipgloss.Center
-	width := layout.Current.Container.Width
-
-	sb := strings.Builder{}
-	util.MapReducePar(m.app.Messages, &sb, func(message opencode.Message) func(*strings.Builder) *strings.Builder {
+	for _, message := range m.app.Messages {
 		var content string
 		var cached bool
-		blocks := make([]string, 0)
 
 		switch message.Role {
 		case opencode.MessageRoleUser:
 			for _, part := range message.Parts {
 				switch part := part.AsUnion().(type) {
 				case opencode.TextPart:
-					key := m.cache.GenerateKey(message.ID, part.Text, layout.Current.Viewport.Width)
+					key := m.cache.GenerateKey(message.ID, part.Text, width, m.selectedPart == m.partCount)
 					content, cached = m.cache.Get(key)
 					if !cached {
 						content = renderText(
+							m.app,
 							message,
 							part.Text,
 							m.app.Info.User,
 							m.showToolDetails,
+							m.partCount == m.selectedPart,
 							width,
-							align,
 						)
 						m.cache.Set(key, content)
 					}
 					if content != "" {
+						if m.selectedPart == m.partCount {
+							m.viewport.SetYOffset(m.lineCount - 4)
+							m.selectedText = part.Text
+						}
 						blocks = append(blocks, content)
+						m.partCount++
+						m.lineCount += lipgloss.Height(content) + 1
 					}
 				}
 			}
@@ -162,33 +188,41 @@ func (m *messagesComponent) renderView() {
 					}
 
 					if finished {
-						key := m.cache.GenerateKey(message.ID, p.Text, layout.Current.Viewport.Width, m.showToolDetails)
+						key := m.cache.GenerateKey(message.ID, p.Text, width, m.showToolDetails, m.selectedPart == m.partCount)
 						content, cached = m.cache.Get(key)
 						if !cached {
 							content = renderText(
+								m.app,
 								message,
 								p.Text,
 								message.Metadata.Assistant.ModelID,
 								m.showToolDetails,
+								m.partCount == m.selectedPart,
 								width,
-								align,
 								toolCallParts...,
 							)
 							m.cache.Set(key, content)
 						}
 					} else {
 						content = renderText(
+							m.app,
 							message,
 							p.Text,
 							message.Metadata.Assistant.ModelID,
 							m.showToolDetails,
+							m.partCount == m.selectedPart,
 							width,
-							align,
 							toolCallParts...,
 						)
 					}
 					if content != "" {
+						if m.selectedPart == m.partCount {
+							m.viewport.SetYOffset(m.lineCount - 4)
+							m.selectedText = p.Text
+						}
 						blocks = append(blocks, content)
+						m.partCount++
+						m.lineCount += lipgloss.Height(content) + 1
 					}
 				case opencode.ToolInvocationPart:
 					if !m.showToolDetails {
@@ -199,29 +233,38 @@ func (m *messagesComponent) renderView() {
 						key := m.cache.GenerateKey(message.ID,
 							part.ToolInvocation.ToolCallID,
 							m.showToolDetails,
-							layout.Current.Viewport.Width,
+							width,
+							m.partCount == m.selectedPart,
 						)
 						content, cached = m.cache.Get(key)
 						if !cached {
 							content = renderToolDetails(
+								m.app,
 								part,
 								message.Metadata,
+								m.partCount == m.selectedPart,
 								width,
-								align,
 							)
 							m.cache.Set(key, content)
 						}
 					} else {
 						// if the tool call isn't finished, don't cache
 						content = renderToolDetails(
+							m.app,
 							part,
 							message.Metadata,
+							m.partCount == m.selectedPart,
 							width,
-							align,
 						)
 					}
 					if content != "" {
+						if m.selectedPart == m.partCount {
+							m.viewport.SetYOffset(m.lineCount - 4)
+							m.selectedText = ""
+						}
 						blocks = append(blocks, content)
+						m.partCount++
+						m.lineCount += lipgloss.Height(content) + 1
 					}
 				}
 			}
@@ -240,41 +283,33 @@ func (m *messagesComponent) renderView() {
 
 		if error != "" {
 			error = renderContentBlock(
+				m.app,
 				error,
+				false,
 				width,
-				align,
 				WithBorderColor(t.Error()),
 			)
 			blocks = append(blocks, error)
+			m.lineCount += lipgloss.Height(error) + 1
 		}
+	}
 
-		str := strings.Join(blocks, "\n\n")
-		return func(sbdr *strings.Builder) *strings.Builder {
-			if sbdr.Len() > 0 && str != "" {
-				sbdr.WriteString("\n\n")
-			}
-			sbdr.WriteString(str)
-			return sbdr
-		}
-	})
-
-	content := sb.String()
-
-	m.viewport.SetHeight(m.height - lipgloss.Height(m.header()) + 1)
-	m.viewport.SetContent("\n" + content)
+	m.viewport.SetContent("\n" + strings.Join(blocks, "\n\n"))
+	if m.selectedPart == m.partCount-1 {
+		m.viewport.GotoBottom()
+	}
 }
 
-func (m *messagesComponent) header() string {
+func (m *messagesComponent) header(width int) string {
 	if m.app.Session.ID == "" {
 		return ""
 	}
 
 	t := theme.CurrentTheme()
-	width := layout.Current.Container.Width
 	base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
 	muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
 	headerLines := []string{}
-	headerLines = append(headerLines, toMarkdown("# "+m.app.Session.Title, width-6, t.Background()))
+	headerLines = append(headerLines, util.ToMarkdown("# "+m.app.Session.Title, width-6, t.Background()))
 	if m.app.Session.Share.URL != "" {
 		headerLines = append(headerLines, muted(m.app.Session.Share.URL))
 	} else {
@@ -297,31 +332,29 @@ func (m *messagesComponent) header() string {
 	return "\n" + header + "\n"
 }
 
-func (m *messagesComponent) View() string {
+func (m *messagesComponent) View(width, height int) string {
 	t := theme.CurrentTheme()
 	if m.rendering {
 		return lipgloss.Place(
-			m.width,
-			m.height+1,
+			width,
+			height,
 			lipgloss.Center,
 			lipgloss.Center,
 			styles.NewStyle().Background(t.Background()).Render("Loading session..."),
 			styles.WhitespaceStyle(t.Background()),
 		)
 	}
-	header := lipgloss.PlaceHorizontal(
-		m.width,
-		lipgloss.Center,
-		m.header(),
-		styles.WhitespaceStyle(t.Background()),
-	)
+	header := m.header(width)
+	m.viewport.SetWidth(width)
+	m.viewport.SetHeight(height - lipgloss.Height(header))
+
 	return styles.NewStyle().
 		Background(t.Background()).
 		Render(header + "\n" + m.viewport.View())
 }
 
-func (m *messagesComponent) SetSize(width, height int) tea.Cmd {
-	if m.width == width && m.height == height {
+func (m *messagesComponent) SetWidth(width int) tea.Cmd {
+	if m.width == width {
 		return nil
 	}
 	// Clear cache on resize since width affects rendering
@@ -329,23 +362,14 @@ func (m *messagesComponent) SetSize(width, height int) tea.Cmd {
 		m.cache.Clear()
 	}
 	m.width = width
-	m.height = height
 	m.viewport.SetWidth(width)
-	m.viewport.SetHeight(height - lipgloss.Height(m.header()))
-	m.attachments.SetWidth(width + 40)
-	m.attachments.SetHeight(3)
-	m.renderView()
+	m.renderView(width)
 	return nil
 }
 
-func (m *messagesComponent) GetSize() (int, int) {
-	return m.width, m.height
-}
-
 func (m *messagesComponent) Reload() tea.Cmd {
-	m.rendering = true
 	return func() tea.Msg {
-		m.renderView()
+		m.renderView(m.width)
 		return renderFinishedMsg{}
 	}
 }
@@ -370,16 +394,45 @@ func (m *messagesComponent) HalfPageDown() (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
+func (m *messagesComponent) Previous() (tea.Model, tea.Cmd) {
+	m.tail = false
+	if m.selectedPart < 0 {
+		m.selectedPart = m.partCount
+	}
+	m.selectedPart--
+	if m.selectedPart < 0 {
+		m.selectedPart = 0
+	}
+	return m, util.CmdHandler(selectedMessagePartChangedMsg{
+		part: m.selectedPart,
+	})
+}
+
+func (m *messagesComponent) Next() (tea.Model, tea.Cmd) {
+	m.tail = false
+	m.selectedPart++
+	if m.selectedPart >= m.partCount {
+		m.selectedPart = m.partCount
+	}
+	return m, util.CmdHandler(selectedMessagePartChangedMsg{
+		part: m.selectedPart,
+	})
+}
+
 func (m *messagesComponent) First() (tea.Model, tea.Cmd) {
-	m.viewport.GotoTop()
+	m.selectedPart = 0
 	m.tail = false
-	return m, nil
+	return m, util.CmdHandler(selectedMessagePartChangedMsg{
+		part: m.selectedPart,
+	})
 }
 
 func (m *messagesComponent) Last() (tea.Model, tea.Cmd) {
-	m.viewport.GotoBottom()
+	m.selectedPart = m.partCount - 1
 	m.tail = true
-	return m, nil
+	return m, util.CmdHandler(selectedMessagePartChangedMsg{
+		part: m.selectedPart,
+	})
 }
 
 func (m *messagesComponent) ToolDetailsVisible() bool {
@@ -388,15 +441,14 @@ func (m *messagesComponent) ToolDetailsVisible() bool {
 
 func NewMessagesComponent(app *app.App) MessagesComponent {
 	vp := viewport.New()
-	attachments := viewport.New()
 	vp.KeyMap = viewport.KeyMap{}
 
 	return &messagesComponent{
 		app:             app,
 		viewport:        vp,
-		attachments:     attachments,
 		showToolDetails: true,
 		cache:           NewMessageCache(),
 		tail:            true,
+		selectedPart:    -1,
 	}
 }

+ 0 - 4
packages/tui/internal/components/commands/commands.go

@@ -34,10 +34,6 @@ func (c *commandsComponent) SetSize(width, height int) tea.Cmd {
 	return nil
 }
 
-func (c *commandsComponent) GetSize() (int, int) {
-	return c.width, c.height
-}
-
 func (c *commandsComponent) SetBackgroundColor(color compat.AdaptiveColor) {
 	c.background = &color
 }

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

@@ -41,7 +41,6 @@ func (ci *CompletionItem) Render(selected bool, width int) string {
 	title := itemStyle.Render(
 		ci.DisplayValue(),
 	)
-
 	return title
 }
 
@@ -59,7 +58,6 @@ func NewCompletionItem(completionItem CompletionItem) CompletionItemI {
 
 type CompletionProvider interface {
 	GetId() string
-	GetEntry() CompletionItemI
 	GetChildEntries(query string) ([]CompletionItemI, error)
 	GetEmptyMessage() string
 }
@@ -175,9 +173,6 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			cmds = append(cmds, c.pseudoSearchTextArea.Focus())
 			return c, tea.Batch(cmds...)
 		}
-	case tea.WindowSizeMsg:
-		c.width = msg.Width
-		c.height = msg.Height
 	}
 
 	return c, tea.Batch(cmds...)

+ 235 - 0
packages/tui/internal/components/dialog/find.go

@@ -0,0 +1,235 @@
+package dialog
+
+import (
+	"log/slog"
+
+	"github.com/charmbracelet/bubbles/v2/key"
+	"github.com/charmbracelet/bubbles/v2/textinput"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/sst/opencode/internal/components/list"
+	"github.com/sst/opencode/internal/components/modal"
+	"github.com/sst/opencode/internal/layout"
+	"github.com/sst/opencode/internal/styles"
+	"github.com/sst/opencode/internal/theme"
+	"github.com/sst/opencode/internal/util"
+)
+
+type FindSelectedMsg struct {
+	FilePath string
+}
+
+type FindDialogCloseMsg struct{}
+
+type FindDialog interface {
+	layout.Modal
+	tea.Model
+	tea.ViewModel
+	SetWidth(width int)
+	SetHeight(height int)
+	IsEmpty() bool
+	SetProvider(provider CompletionProvider)
+}
+
+type findDialogComponent struct {
+	query              string
+	completionProvider CompletionProvider
+	width, height      int
+	modal              *modal.Modal
+	textInput          textinput.Model
+	list               list.List[CompletionItemI]
+}
+
+type findDialogKeyMap struct {
+	Select key.Binding
+	Cancel key.Binding
+}
+
+var findDialogKeys = findDialogKeyMap{
+	Select: key.NewBinding(
+		key.WithKeys("enter"),
+	),
+	Cancel: key.NewBinding(
+		key.WithKeys("esc"),
+	),
+}
+
+func (f *findDialogComponent) Init() tea.Cmd {
+	return textinput.Blink
+}
+
+func (f *findDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var cmd tea.Cmd
+	var cmds []tea.Cmd
+
+	switch msg := msg.(type) {
+	case []CompletionItemI:
+		f.list.SetItems(msg)
+	case tea.KeyMsg:
+		switch msg.String() {
+		case "ctrl+c":
+			if f.textInput.Value() == "" {
+				return f, nil
+			}
+			f.textInput.SetValue("")
+			return f.update(msg)
+		}
+
+		switch {
+		case key.Matches(msg, findDialogKeys.Select):
+			item, i := f.list.GetSelectedItem()
+			if i == -1 {
+				return f, nil
+			}
+			return f, f.selectFile(item)
+		case key.Matches(msg, findDialogKeys.Cancel):
+			return f, f.Close()
+		default:
+			f.textInput, cmd = f.textInput.Update(msg)
+			cmds = append(cmds, cmd)
+
+			f, cmd = f.update(msg)
+			cmds = append(cmds, cmd)
+		}
+	}
+
+	return f, tea.Batch(cmds...)
+}
+
+func (f *findDialogComponent) update(msg tea.Msg) (*findDialogComponent, tea.Cmd) {
+	var cmd tea.Cmd
+	var cmds []tea.Cmd
+
+	query := f.textInput.Value()
+	if query != f.query {
+		f.query = query
+		cmd = func() tea.Msg {
+			items, err := f.completionProvider.GetChildEntries(query)
+			if err != nil {
+				slog.Error("Failed to get completion items", "error", err)
+			}
+			return items
+		}
+		cmds = append(cmds, cmd)
+	}
+
+	u, cmd := f.list.Update(msg)
+	f.list = u.(list.List[CompletionItemI])
+	cmds = append(cmds, cmd)
+
+	return f, tea.Batch(cmds...)
+}
+
+func (f *findDialogComponent) View() string {
+	t := theme.CurrentTheme()
+	f.textInput.SetWidth(f.width - 8)
+	f.list.SetMaxWidth(f.width - 4)
+	inputView := f.textInput.View()
+	inputView = styles.NewStyle().
+		Background(t.BackgroundPanel()).
+		Height(1).
+		Width(f.width-4).
+		Padding(0, 0).
+		Render(inputView)
+
+	listView := f.list.View()
+	return styles.NewStyle().Height(12).Render(inputView + "\n" + listView)
+}
+
+func (f *findDialogComponent) SetWidth(width int) {
+	f.width = width
+	if width > 4 {
+		f.textInput.SetWidth(width - 4)
+		f.list.SetMaxWidth(width - 4)
+	}
+}
+
+func (f *findDialogComponent) SetHeight(height int) {
+	f.height = height
+}
+
+func (f *findDialogComponent) IsEmpty() bool {
+	return f.list.IsEmpty()
+}
+
+func (f *findDialogComponent) SetProvider(provider CompletionProvider) {
+	f.completionProvider = provider
+	f.list.SetEmptyMessage(" " + provider.GetEmptyMessage())
+	f.list.SetItems([]CompletionItemI{})
+}
+
+func (f *findDialogComponent) selectFile(item CompletionItemI) tea.Cmd {
+	return tea.Sequence(
+		f.Close(),
+		util.CmdHandler(FindSelectedMsg{
+			FilePath: item.GetValue(),
+		}),
+	)
+}
+
+func (f *findDialogComponent) Render(background string) string {
+	return f.modal.Render(f.View(), background)
+}
+
+func (f *findDialogComponent) Close() tea.Cmd {
+	f.textInput.Reset()
+	f.textInput.Blur()
+	return util.CmdHandler(modal.CloseModalMsg{})
+}
+
+func createTextInput(existing *textinput.Model) textinput.Model {
+	t := theme.CurrentTheme()
+	bgColor := t.BackgroundPanel()
+	textColor := t.Text()
+	textMutedColor := t.TextMuted()
+
+	ti := textinput.New()
+
+	ti.Styles.Blurred.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss()
+	ti.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
+	ti.Styles.Focused.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss()
+	ti.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
+	ti.Styles.Cursor.Color = t.Primary()
+	ti.VirtualCursor = true
+
+	ti.Prompt = " "
+	ti.CharLimit = -1
+	ti.Focus()
+
+	if existing != nil {
+		ti.SetValue(existing.Value())
+		ti.SetWidth(existing.Width())
+	}
+
+	return ti
+}
+
+func NewFindDialog(completionProvider CompletionProvider) FindDialog {
+	ti := createTextInput(nil)
+
+	li := list.NewListComponent(
+		[]CompletionItemI{},
+		10, // max visible items
+		completionProvider.GetEmptyMessage(),
+		false,
+	)
+
+	// Load initial items
+	go func() {
+		items, err := completionProvider.GetChildEntries("")
+		if err != nil {
+			slog.Error("Failed to get completion items", "error", err)
+		}
+		li.SetItems(items)
+	}()
+
+	return &findDialogComponent{
+		query:              "",
+		completionProvider: completionProvider,
+		textInput:          ti,
+		list:               li,
+		modal: modal.New(
+			modal.WithTitle("Find Files"),
+			modal.WithMaxWidth(80),
+		),
+	}
+}

+ 15 - 44
packages/tui/internal/components/diff/diff.go

@@ -73,44 +73,6 @@ type linePair struct {
 	right *DiffLine
 }
 
-// -------------------------------------------------------------------------
-// Side-by-Side Configuration
-// -------------------------------------------------------------------------
-
-// SideBySideConfig configures the rendering of side-by-side diffs
-type SideBySideConfig struct {
-	TotalWidth int
-}
-
-// SideBySideOption modifies a SideBySideConfig
-type SideBySideOption func(*SideBySideConfig)
-
-// NewSideBySideConfig creates a SideBySideConfig with default values
-func NewSideBySideConfig(opts ...SideBySideOption) SideBySideConfig {
-	config := SideBySideConfig{
-		TotalWidth: 160, // Default width for side-by-side view
-	}
-
-	for _, opt := range opts {
-		opt(&config)
-	}
-
-	return config
-}
-
-// WithTotalWidth sets the total width for side-by-side view
-func WithTotalWidth(width int) SideBySideOption {
-	return func(s *SideBySideConfig) {
-		if width > 0 {
-			s.TotalWidth = width
-		}
-	}
-}
-
-// -------------------------------------------------------------------------
-// Unified Configuration
-// -------------------------------------------------------------------------
-
 // UnifiedConfig configures the rendering of unified diffs
 type UnifiedConfig struct {
 	Width int
@@ -122,13 +84,22 @@ type UnifiedOption func(*UnifiedConfig)
 // NewUnifiedConfig creates a UnifiedConfig with default values
 func NewUnifiedConfig(opts ...UnifiedOption) UnifiedConfig {
 	config := UnifiedConfig{
-		Width: 80, // Default width for unified view
+		Width: 80,
 	}
-
 	for _, opt := range opts {
 		opt(&config)
 	}
+	return config
+}
 
+// NewSideBySideConfig creates a SideBySideConfig with default values
+func NewSideBySideConfig(opts ...UnifiedOption) UnifiedConfig {
+	config := UnifiedConfig{
+		Width: 160,
+	}
+	for _, opt := range opts {
+		opt(&config)
+	}
 	return config
 }
 
@@ -907,7 +878,7 @@ func RenderUnifiedHunk(fileName string, h Hunk, opts ...UnifiedOption) string {
 }
 
 // RenderSideBySideHunk formats a hunk for side-by-side display
-func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) string {
+func RenderSideBySideHunk(fileName string, h Hunk, opts ...UnifiedOption) string {
 	// Apply options to create the configuration
 	config := NewSideBySideConfig(opts...)
 
@@ -922,10 +893,10 @@ func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) str
 	pairs := pairLines(hunkCopy.Lines)
 
 	// Calculate column width
-	colWidth := config.TotalWidth / 2
+	colWidth := config.Width / 2
 
 	leftWidth := colWidth
-	rightWidth := config.TotalWidth - colWidth
+	rightWidth := config.Width - colWidth
 	var sb strings.Builder
 
 	util.WriteStringsPar(&sb, pairs, func(p linePair) string {
@@ -963,7 +934,7 @@ func FormatUnifiedDiff(filename string, diffText string, opts ...UnifiedOption)
 }
 
 // FormatDiff creates a side-by-side formatted view of a diff
-func FormatDiff(filename string, diffText string, opts ...SideBySideOption) (string, error) {
+func FormatDiff(filename string, diffText string, opts ...UnifiedOption) (string, error) {
 	diffResult, err := ParseUnifiedDiff(diffText)
 	if err != nil {
 		return "", err

+ 281 - 0
packages/tui/internal/components/fileviewer/fileviewer.go

@@ -0,0 +1,281 @@
+package fileviewer
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/charmbracelet/bubbles/v2/viewport"
+	tea "github.com/charmbracelet/bubbletea/v2"
+
+	"github.com/sst/opencode/internal/app"
+	"github.com/sst/opencode/internal/commands"
+	"github.com/sst/opencode/internal/components/dialog"
+	"github.com/sst/opencode/internal/components/diff"
+	"github.com/sst/opencode/internal/layout"
+	"github.com/sst/opencode/internal/styles"
+	"github.com/sst/opencode/internal/theme"
+	"github.com/sst/opencode/internal/util"
+)
+
+type DiffStyle int
+
+const (
+	DiffStyleSplit DiffStyle = iota
+	DiffStyleUnified
+)
+
+type Model struct {
+	app           *app.App
+	width, height int
+	viewport      viewport.Model
+	filename      *string
+	content       *string
+	isDiff        *bool
+	diffStyle     DiffStyle
+}
+
+type fileRenderedMsg struct {
+	content string
+}
+
+func New(app *app.App) Model {
+	vp := viewport.New()
+	m := Model{
+		app:       app,
+		viewport:  vp,
+		diffStyle: DiffStyleUnified,
+	}
+	if app.State.SplitDiff {
+		m.diffStyle = DiffStyleSplit
+	}
+	return m
+}
+
+func (m Model) Init() tea.Cmd {
+	return m.viewport.Init()
+}
+
+func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
+	var cmds []tea.Cmd
+
+	switch msg := msg.(type) {
+	case fileRenderedMsg:
+		m.viewport.SetContent(msg.content)
+		return m, util.CmdHandler(app.FileRenderedMsg{
+			FilePath: *m.filename,
+		})
+	case dialog.ThemeSelectedMsg:
+		return m, m.render()
+	case tea.KeyMsg:
+		switch msg.String() {
+		// TODO
+		}
+	}
+
+	vp, cmd := m.viewport.Update(msg)
+	m.viewport = vp
+	cmds = append(cmds, cmd)
+
+	return m, tea.Batch(cmds...)
+}
+
+func (m Model) View() string {
+	if !m.HasFile() {
+		return ""
+	}
+
+	header := *m.filename
+	header = styles.NewStyle().
+		Padding(1, 2).
+		Width(m.width).
+		Background(theme.CurrentTheme().BackgroundElement()).
+		Foreground(theme.CurrentTheme().Text()).
+		Render(header)
+
+	t := theme.CurrentTheme()
+
+	close := m.app.Key(commands.FileCloseCommand)
+	diffToggle := m.app.Key(commands.FileDiffToggleCommand)
+	if m.isDiff == nil || *m.isDiff == false {
+		diffToggle = ""
+	}
+	layoutToggle := m.app.Key(commands.MessagesLayoutToggleCommand)
+
+	background := t.Background()
+	footer := layout.Render(
+		layout.FlexOptions{
+			Background: &background,
+			Direction:  layout.Row,
+			Justify:    layout.JustifyCenter,
+			Align:      layout.AlignStretch,
+			Width:      m.width - 2,
+			Gap:        5,
+		},
+		layout.FlexItem{
+			View: close,
+		},
+		layout.FlexItem{
+			View: layoutToggle,
+		},
+		layout.FlexItem{
+			View: diffToggle,
+		},
+	)
+	footer = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(footer)
+
+	return header + "\n" + m.viewport.View() + "\n" + footer
+}
+
+func (m *Model) Clear() (Model, tea.Cmd) {
+	m.filename = nil
+	m.content = nil
+	m.isDiff = nil
+	return *m, m.render()
+}
+
+func (m *Model) ToggleDiff() (Model, tea.Cmd) {
+	switch m.diffStyle {
+	case DiffStyleSplit:
+		m.diffStyle = DiffStyleUnified
+	default:
+		m.diffStyle = DiffStyleSplit
+	}
+	return *m, m.render()
+}
+
+func (m *Model) DiffStyle() DiffStyle {
+	return m.diffStyle
+}
+
+func (m Model) HasFile() bool {
+	return m.filename != nil && m.content != nil
+}
+
+func (m Model) Filename() string {
+	if m.filename == nil {
+		return ""
+	}
+	return *m.filename
+}
+
+func (m *Model) SetSize(width, height int) (Model, tea.Cmd) {
+	if m.width != width || m.height != height {
+		m.width = width
+		m.height = height
+		m.viewport.SetWidth(width)
+		m.viewport.SetHeight(height - 4)
+		return *m, m.render()
+	}
+	return *m, nil
+}
+
+func (m *Model) SetFile(filename string, content string, isDiff bool) (Model, tea.Cmd) {
+	m.filename = &filename
+	m.content = &content
+	m.isDiff = &isDiff
+	return *m, m.render()
+}
+
+func (m *Model) render() tea.Cmd {
+	if m.filename == nil || m.content == nil {
+		m.viewport.SetContent("")
+		return nil
+	}
+
+	return func() tea.Msg {
+		t := theme.CurrentTheme()
+		var rendered string
+
+		if m.isDiff != nil && *m.isDiff {
+			diffResult := ""
+			var err error
+			if m.diffStyle == DiffStyleSplit {
+				diffResult, err = diff.FormatDiff(
+					*m.filename,
+					*m.content,
+					diff.WithWidth(m.width),
+				)
+			} else if m.diffStyle == DiffStyleUnified {
+				diffResult, err = diff.FormatUnifiedDiff(
+					*m.filename,
+					*m.content,
+					diff.WithWidth(m.width),
+				)
+			}
+			if err != nil {
+				rendered = styles.NewStyle().
+					Foreground(t.Error()).
+					Render(fmt.Sprintf("Error rendering diff: %v", err))
+			} else {
+				rendered = strings.TrimRight(diffResult, "\n")
+			}
+		} else {
+			rendered = util.RenderFile(
+				*m.filename,
+				*m.content,
+				m.width,
+			)
+		}
+
+		rendered = styles.NewStyle().
+			Width(m.width).
+			Background(t.BackgroundPanel()).
+			Render(rendered)
+
+		return fileRenderedMsg{
+			content: rendered,
+		}
+	}
+}
+
+func (m *Model) ScrollTo(line int) {
+	m.viewport.SetYOffset(line)
+}
+
+func (m *Model) ScrollToBottom() {
+	m.viewport.GotoBottom()
+}
+
+func (m *Model) ScrollToTop() {
+	m.viewport.GotoTop()
+}
+
+func (m *Model) PageUp() (Model, tea.Cmd) {
+	m.viewport.ViewUp()
+	return *m, nil
+}
+
+func (m *Model) PageDown() (Model, tea.Cmd) {
+	m.viewport.ViewDown()
+	return *m, nil
+}
+
+func (m *Model) HalfPageUp() (Model, tea.Cmd) {
+	m.viewport.HalfViewUp()
+	return *m, nil
+}
+
+func (m *Model) HalfPageDown() (Model, tea.Cmd) {
+	m.viewport.HalfViewDown()
+	return *m, nil
+}
+
+func (m Model) AtTop() bool {
+	return m.viewport.AtTop()
+}
+
+func (m Model) AtBottom() bool {
+	return m.viewport.AtBottom()
+}
+
+func (m Model) ScrollPercent() float64 {
+	return m.viewport.ScrollPercent()
+}
+
+func (m Model) TotalLineCount() int {
+	return m.viewport.TotalLineCount()
+}
+
+func (m Model) VisibleLineCount() int {
+	return m.viewport.VisibleLineCount()
+}

+ 2 - 2
packages/tui/internal/components/modal/modal.go

@@ -135,11 +135,11 @@ func (m *Modal) Render(contentView string, background string) string {
 	col := (bgWidth - modalWidth) / 2
 
 	return layout.PlaceOverlay(
-		col,
+		col-1, // TODO: whyyyyy
 		row,
 		modalView,
 		background,
 		layout.WithOverlayBorder(),
-		layout.WithOverlayBorderColor(t.Primary()),
+		layout.WithOverlayBorderColor(t.BorderActive()),
 	)
 }

+ 2 - 0
packages/tui/internal/config/config.go

@@ -21,6 +21,8 @@ type State struct {
 	Provider           string       `toml:"provider"`
 	Model              string       `toml:"model"`
 	RecentlyUsedModels []ModelUsage `toml:"recently_used_models"`
+	MessagesRight      bool         `toml:"messages_right"`
+	SplitDiff          bool         `toml:"split_diff"`
 }
 
 func NewState() *State {

+ 97 - 26
packages/tui/internal/layout/flex.go

@@ -4,7 +4,9 @@ import (
 	"strings"
 
 	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/charmbracelet/lipgloss/v2/compat"
 	"github.com/sst/opencode/internal/styles"
+	"github.com/sst/opencode/internal/theme"
 )
 
 type Direction int
@@ -34,11 +36,13 @@ const (
 )
 
 type FlexOptions struct {
-	Direction Direction
-	Justify   Justify
-	Align     Align
-	Width     int
-	Height    int
+	Background *compat.AdaptiveColor
+	Direction  Direction
+	Justify    Justify
+	Align      Align
+	Width      int
+	Height     int
+	Gap        int
 }
 
 type FlexItem struct {
@@ -53,6 +57,12 @@ func Render(opts FlexOptions, items ...FlexItem) string {
 		return ""
 	}
 
+	t := theme.CurrentTheme()
+	if opts.Background == nil {
+		background := t.Background()
+		opts.Background = &background
+	}
+
 	// Calculate dimensions for each item
 	mainAxisSize := opts.Width
 	crossAxisSize := opts.Height
@@ -72,8 +82,14 @@ func Render(opts FlexOptions, items ...FlexItem) string {
 		}
 	}
 
+	// Account for gaps between items
+	totalGapSize := 0
+	if len(items) > 1 && opts.Gap > 0 {
+		totalGapSize = opts.Gap * (len(items) - 1)
+	}
+
 	// Calculate available space for grow items
-	availableSpace := max(mainAxisSize-totalFixedSize, 0)
+	availableSpace := max(mainAxisSize-totalFixedSize-totalGapSize, 0)
 
 	// Calculate size for each grow item
 	growItemSize := 0
@@ -108,6 +124,7 @@ func Render(opts FlexOptions, items ...FlexItem) string {
 			// For row direction, constrain width and handle height alignment
 			if itemSize > 0 {
 				view = styles.NewStyle().
+					Background(*opts.Background).
 					Width(itemSize).
 					Height(crossAxisSize).
 					Render(view)
@@ -116,31 +133,65 @@ func Render(opts FlexOptions, items ...FlexItem) string {
 			// Apply cross-axis alignment
 			switch opts.Align {
 			case AlignCenter:
-				view = lipgloss.PlaceVertical(crossAxisSize, lipgloss.Center, view)
+				view = lipgloss.PlaceVertical(
+					crossAxisSize,
+					lipgloss.Center,
+					view,
+					styles.WhitespaceStyle(*opts.Background),
+				)
 			case AlignEnd:
-				view = lipgloss.PlaceVertical(crossAxisSize, lipgloss.Bottom, view)
+				view = lipgloss.PlaceVertical(
+					crossAxisSize,
+					lipgloss.Bottom,
+					view,
+					styles.WhitespaceStyle(*opts.Background),
+				)
 			case AlignStart:
-				view = lipgloss.PlaceVertical(crossAxisSize, lipgloss.Top, view)
+				view = lipgloss.PlaceVertical(
+					crossAxisSize,
+					lipgloss.Top,
+					view,
+					styles.WhitespaceStyle(*opts.Background),
+				)
 			case AlignStretch:
 				// Already stretched by Height setting above
 			}
 		} else {
 			// For column direction, constrain height and handle width alignment
 			if itemSize > 0 {
-				view = styles.NewStyle().
-					Height(itemSize).
-					Width(crossAxisSize).
-					Render(view)
+				style := styles.NewStyle().
+					Background(*opts.Background).
+					Height(itemSize)
+				// Only set width for stretch alignment
+				if opts.Align == AlignStretch {
+					style = style.Width(crossAxisSize)
+				}
+				view = style.Render(view)
 			}
 
 			// Apply cross-axis alignment
 			switch opts.Align {
 			case AlignCenter:
-				view = lipgloss.PlaceHorizontal(crossAxisSize, lipgloss.Center, view)
+				view = lipgloss.PlaceHorizontal(
+					crossAxisSize,
+					lipgloss.Center,
+					view,
+					styles.WhitespaceStyle(*opts.Background),
+				)
 			case AlignEnd:
-				view = lipgloss.PlaceHorizontal(crossAxisSize, lipgloss.Right, view)
+				view = lipgloss.PlaceHorizontal(
+					crossAxisSize,
+					lipgloss.Right,
+					view,
+					styles.WhitespaceStyle(*opts.Background),
+				)
 			case AlignStart:
-				view = lipgloss.PlaceHorizontal(crossAxisSize, lipgloss.Left, view)
+				view = lipgloss.PlaceHorizontal(
+					crossAxisSize,
+					lipgloss.Left,
+					view,
+					styles.WhitespaceStyle(*opts.Background),
+				)
 			case AlignStretch:
 				// Already stretched by Width setting above
 			}
@@ -154,11 +205,14 @@ func Render(opts FlexOptions, items ...FlexItem) string {
 		}
 	}
 
-	// Calculate total actual size
+	// Calculate total actual size including gaps
 	totalActualSize := 0
 	for _, size := range actualSizes {
 		totalActualSize += size
 	}
+	if len(items) > 1 && opts.Gap > 0 {
+		totalActualSize += opts.Gap * (len(items) - 1)
+	}
 
 	// Apply justification
 	remainingSpace := max(mainAxisSize-totalActualSize, 0)
@@ -191,12 +245,17 @@ func Render(opts FlexOptions, items ...FlexItem) string {
 	// Build the final layout
 	var parts []string
 
+	spaceStyle := styles.NewStyle().Background(*opts.Background)
 	// Add space before if needed
 	if spaceBefore > 0 {
 		if opts.Direction == Row {
-			parts = append(parts, strings.Repeat(" ", spaceBefore))
+			space := strings.Repeat(" ", spaceBefore)
+			parts = append(parts, spaceStyle.Render(space))
 		} else {
-			parts = append(parts, strings.Repeat("\n", spaceBefore))
+			// For vertical layout, add empty lines as separate parts
+			for range spaceBefore {
+				parts = append(parts, "")
+			}
 		}
 	}
 
@@ -205,11 +264,19 @@ func Render(opts FlexOptions, items ...FlexItem) string {
 		parts = append(parts, view)
 
 		// Add space between items (not after the last one)
-		if i < len(sizedViews)-1 && spaceBetween > 0 {
-			if opts.Direction == Row {
-				parts = append(parts, strings.Repeat(" ", spaceBetween))
-			} else {
-				parts = append(parts, strings.Repeat("\n", spaceBetween))
+		if i < len(sizedViews)-1 {
+			// Add gap first, then any additional spacing from justification
+			totalSpacing := opts.Gap + spaceBetween
+			if totalSpacing > 0 {
+				if opts.Direction == Row {
+					space := strings.Repeat(" ", totalSpacing)
+					parts = append(parts, spaceStyle.Render(space))
+				} else {
+					// For vertical layout, add empty lines as separate parts
+					for range totalSpacing {
+						parts = append(parts, "")
+					}
+				}
 			}
 		}
 	}
@@ -217,9 +284,13 @@ func Render(opts FlexOptions, items ...FlexItem) string {
 	// Add space after if needed
 	if spaceAfter > 0 {
 		if opts.Direction == Row {
-			parts = append(parts, strings.Repeat(" ", spaceAfter))
+			space := strings.Repeat(" ", spaceAfter)
+			parts = append(parts, spaceStyle.Render(space))
 		} else {
-			parts = append(parts, strings.Repeat("\n", spaceAfter))
+			// For vertical layout, add empty lines as separate parts
+			for range spaceAfter {
+				parts = append(parts, "")
+			}
 		}
 	}
 

+ 41 - 0
packages/tui/internal/layout/flex_example_test.go

@@ -0,0 +1,41 @@
+package layout_test
+
+import (
+	"fmt"
+	"github.com/sst/opencode/internal/layout"
+)
+
+func ExampleRender_withGap() {
+	// Create a horizontal layout with 3px gap between items
+	result := layout.Render(
+		layout.FlexOptions{
+			Direction: layout.Row,
+			Width:     30,
+			Height:    1,
+			Gap:       3,
+		},
+		layout.FlexItem{View: "Item1"},
+		layout.FlexItem{View: "Item2"},
+		layout.FlexItem{View: "Item3"},
+	)
+	fmt.Println(result)
+	// Output: Item1   Item2   Item3
+}
+
+func ExampleRender_withGapAndJustify() {
+	// Create a horizontal layout with gap and space-between justification
+	result := layout.Render(
+		layout.FlexOptions{
+			Direction: layout.Row,
+			Width:     30,
+			Height:    1,
+			Gap:       2,
+			Justify:   layout.JustifySpaceBetween,
+		},
+		layout.FlexItem{View: "A"},
+		layout.FlexItem{View: "B"},
+		layout.FlexItem{View: "C"},
+	)
+	fmt.Println(result)
+	// Output: A             B             C
+}

+ 90 - 0
packages/tui/internal/layout/flex_test.go

@@ -0,0 +1,90 @@
+package layout
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestFlexGap(t *testing.T) {
+	tests := []struct {
+		name     string
+		opts     FlexOptions
+		items    []FlexItem
+		expected string
+	}{
+		{
+			name: "Row with gap",
+			opts: FlexOptions{
+				Direction: Row,
+				Width:     20,
+				Height:    1,
+				Gap:       2,
+			},
+			items: []FlexItem{
+				{View: "A"},
+				{View: "B"},
+				{View: "C"},
+			},
+			expected: "A  B  C",
+		},
+		{
+			name: "Column with gap",
+			opts: FlexOptions{
+				Direction: Column,
+				Width:     1,
+				Height:    5,
+				Gap:       1,
+				Align:     AlignStart,
+			},
+			items: []FlexItem{
+				{View: "A", FixedSize: 1},
+				{View: "B", FixedSize: 1},
+				{View: "C", FixedSize: 1},
+			},
+			expected: "A\n \nB\n \nC",
+		},
+		{
+			name: "Row with gap and justify space between",
+			opts: FlexOptions{
+				Direction: Row,
+				Width:     15,
+				Height:    1,
+				Gap:       1,
+				Justify:   JustifySpaceBetween,
+			},
+			items: []FlexItem{
+				{View: "A"},
+				{View: "B"},
+				{View: "C"},
+			},
+			expected: "A      B      C",
+		},
+		{
+			name: "No gap specified",
+			opts: FlexOptions{
+				Direction: Row,
+				Width:     10,
+				Height:    1,
+			},
+			items: []FlexItem{
+				{View: "A"},
+				{View: "B"},
+				{View: "C"},
+			},
+			expected: "ABC",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := Render(tt.opts, tt.items...)
+			// Trim any trailing spaces for comparison
+			result = strings.TrimRight(result, " ")
+			expected := strings.TrimRight(tt.expected, " ")
+
+			if result != expected {
+				t.Errorf("Render() = %q, want %q", result, expected)
+			}
+		})
+	}
+}

+ 303 - 102
packages/tui/internal/tui/tui.go

@@ -19,6 +19,7 @@ import (
 	"github.com/sst/opencode/internal/components/chat"
 	cmdcomp "github.com/sst/opencode/internal/components/commands"
 	"github.com/sst/opencode/internal/components/dialog"
+	"github.com/sst/opencode/internal/components/fileviewer"
 	"github.com/sst/opencode/internal/components/modal"
 	"github.com/sst/opencode/internal/components/status"
 	"github.com/sst/opencode/internal/components/toast"
@@ -40,6 +41,7 @@ const (
 )
 
 const interruptDebounceTimeout = 1 * time.Second
+const fileViewerFullWidthCutoff = 200
 
 type appModel struct {
 	width, height        int
@@ -56,6 +58,12 @@ type appModel struct {
 	toastManager         *toast.ToastManager
 	interruptKeyState    InterruptKeyState
 	lastScroll           time.Time
+	messagesRight        bool
+	fileViewer           fileviewer.Model
+	lastMouse            tea.Mouse
+	fileViewerStart      int
+	fileViewerEnd        int
+	fileViewerHit        bool
 }
 
 func (a appModel) Init() tea.Cmd {
@@ -71,6 +79,7 @@ func (a appModel) Init() tea.Cmd {
 	cmds = append(cmds, a.status.Init())
 	cmds = append(cmds, a.completions.Init())
 	cmds = append(cmds, a.toastManager.Init())
+	cmds = append(cmds, a.fileViewer.Init())
 
 	// Check if we should show the init dialog
 	cmds = append(cmds, func() tea.Msg {
@@ -99,6 +108,7 @@ var BUGGED_SCROLL_KEYS = map[string]bool{
 }
 
 func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var cmd tea.Cmd
 	var cmds []tea.Cmd
 
 	switch msg := msg.(type) {
@@ -112,10 +122,20 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if a.modal != nil {
 			switch keyString {
 			// Escape always closes current modal
-			case "esc", "ctrl+c":
+			case "esc":
 				cmd := a.modal.Close()
 				a.modal = nil
 				return a, cmd
+			case "ctrl+c":
+				// give the modal a chance to handle the ctrl+c
+				updatedModal, cmd := a.modal.Update(msg)
+				a.modal = updatedModal.(layout.Modal)
+				if cmd != nil {
+					return a, cmd
+				}
+				cmd = a.modal.Close()
+				a.modal = nil
+				return a, cmd
 			}
 
 			// Pass all other key presses to the modal
@@ -246,10 +266,28 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if a.modal != nil {
 			return a, nil
 		}
-		updated, cmd := a.messages.Update(msg)
-		a.messages = updated.(chat.MessagesComponent)
-		cmds = append(cmds, cmd)
+
+		var cmd tea.Cmd
+		if a.fileViewerHit {
+			a.fileViewer, cmd = a.fileViewer.Update(msg)
+			cmds = append(cmds, cmd)
+		} else {
+			updated, cmd := a.messages.Update(msg)
+			a.messages = updated.(chat.MessagesComponent)
+			cmds = append(cmds, cmd)
+		}
+
 		return a, tea.Batch(cmds...)
+	case tea.MouseMotionMsg:
+		a.lastMouse = msg.Mouse()
+		a.fileViewerHit = a.fileViewer.HasFile() &&
+			a.lastMouse.X > a.fileViewerStart &&
+			a.lastMouse.X < a.fileViewerEnd
+	case tea.MouseClickMsg:
+		a.lastMouse = msg.Mouse()
+		a.fileViewerHit = a.fileViewer.HasFile() &&
+			a.lastMouse.X > a.fileViewerStart &&
+			a.lastMouse.X < a.fileViewerEnd
 	case tea.BackgroundColorMsg:
 		styles.Terminal = &styles.TerminalInfo{
 			Background:       msg.Color,
@@ -266,6 +304,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 		}
 	case modal.CloseModalMsg:
+		a.editor.Focus()
 		var cmd tea.Cmd
 		if a.modal != nil {
 			cmd = a.modal.Close()
@@ -349,22 +388,47 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			slog.Error("Server error", "name", err.Name, "message", err.Data.Message)
 			return a, toast.NewErrorToast(err.Data.Message, toast.WithTitle(string(err.Name)))
 		}
+	case opencode.EventListResponseEventFileWatcherUpdated:
+		if a.fileViewer.HasFile() {
+			if a.fileViewer.Filename() == msg.Properties.File {
+				return a.openFile(msg.Properties.File)
+			}
+		}
 	case tea.WindowSizeMsg:
 		msg.Height -= 2 // Make space for the status bar
 		a.width, a.height = msg.Width, msg.Height
+		container := min(a.width, 84)
+		if a.fileViewer.HasFile() {
+			if a.width < fileViewerFullWidthCutoff {
+				container = a.width
+			} else {
+				container = min(min(a.width, max(a.width/2, 50)), 84)
+			}
+		}
 		layout.Current = &layout.LayoutInfo{
 			Viewport: layout.Dimensions{
 				Width:  a.width,
 				Height: a.height,
 			},
 			Container: layout.Dimensions{
-				Width: min(a.width, 80),
+				Width: container,
 			},
 		}
-		// Update child component sizes
-		messagesHeight := a.height - 6 // Leave room for editor and status bar
-		a.messages.SetSize(a.width, messagesHeight)
-		a.editor.SetSize(min(a.width, 80), 5)
+		mainWidth := layout.Current.Container.Width
+		a.messages.SetWidth(mainWidth - 4)
+
+		sideWidth := a.width - mainWidth
+		if a.width < fileViewerFullWidthCutoff {
+			sideWidth = a.width
+		}
+		a.fileViewerStart = mainWidth
+		a.fileViewerEnd = a.fileViewerStart + sideWidth
+		if a.messagesRight {
+			a.fileViewerStart = 0
+			a.fileViewerEnd = sideWidth
+		}
+		a.fileViewer, cmd = a.fileViewer.SetSize(sideWidth, layout.Current.Viewport.Height)
+		cmds = append(cmds, cmd)
 	case app.SessionSelectedMsg:
 		messages, err := a.app.ListMessages(context.Background(), msg.ID)
 		if err != nil {
@@ -373,6 +437,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 		a.app.Session = msg
 		a.app.Messages = messages
+		return a, util.CmdHandler(app.SessionLoadedMsg{})
 	case app.ModelSelectedMsg:
 		a.app.Provider = &msg.Provider
 		a.app.Model = &msg.Model
@@ -395,24 +460,22 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		// Reset interrupt key state after timeout
 		a.interruptKeyState = InterruptKeyIdle
 		a.editor.SetInterruptKeyInDebounce(false)
+	case dialog.FindSelectedMsg:
+		return a.openFile(msg.FilePath)
 	}
 
-	// update status bar
 	s, cmd := a.status.Update(msg)
 	cmds = append(cmds, cmd)
 	a.status = s.(status.StatusComponent)
 
-	// update editor
 	u, cmd := a.editor.Update(msg)
 	a.editor = u.(chat.EditorComponent)
 	cmds = append(cmds, cmd)
 
-	// update messages
 	u, cmd = a.messages.Update(msg)
 	a.messages = u.(chat.MessagesComponent)
 	cmds = append(cmds, cmd)
 
-	// update modal
 	if a.modal != nil {
 		u, cmd := a.modal.Update(msg)
 		a.modal = u.(layout.Modal)
@@ -425,86 +488,95 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		cmds = append(cmds, cmd)
 	}
 
+	fv, cmd := a.fileViewer.Update(msg)
+	a.fileViewer = fv
+	cmds = append(cmds, cmd)
+
 	return a, tea.Batch(cmds...)
 }
 
 func (a appModel) View() string {
-	mainLayout := a.chat(layout.Current.Container.Width, lipgloss.Center)
+	t := theme.CurrentTheme()
+
+	var mainLayout string
+	mainWidth := layout.Current.Container.Width - 4
+	if a.app.Session.ID == "" {
+		mainLayout = a.home(mainWidth)
+	} else {
+		mainLayout = a.chat(mainWidth)
+	}
+	mainLayout = styles.NewStyle().
+		Background(t.Background()).
+		Padding(0, 2).
+		Render(mainLayout)
+
+	mainHeight := lipgloss.Height(mainLayout)
+
+	if a.fileViewer.HasFile() {
+		file := a.fileViewer.View()
+		baseStyle := styles.NewStyle().Background(t.BackgroundPanel())
+		sidePanel := baseStyle.Height(mainHeight).Render(file)
+		if a.width >= fileViewerFullWidthCutoff {
+			if a.messagesRight {
+				mainLayout = lipgloss.JoinHorizontal(
+					lipgloss.Top,
+					sidePanel,
+					mainLayout,
+				)
+			} else {
+				mainLayout = lipgloss.JoinHorizontal(
+					lipgloss.Top,
+					mainLayout,
+					sidePanel,
+				)
+			}
+		} else {
+			mainLayout = sidePanel
+		}
+	} else {
+		mainLayout = lipgloss.PlaceHorizontal(
+			a.width,
+			lipgloss.Center,
+			mainLayout,
+			styles.WhitespaceStyle(t.Background()),
+		)
+	}
+
+	mainStyle := styles.NewStyle().Background(t.Background())
+	mainLayout = mainStyle.Render(mainLayout)
+
 	if a.modal != nil {
 		mainLayout = a.modal.Render(mainLayout)
 	}
 	mainLayout = a.toastManager.RenderOverlay(mainLayout)
+
 	if theme.CurrentThemeUsesAnsiColors() {
 		mainLayout = util.ConvertRGBToAnsi16Colors(mainLayout)
 	}
 	return mainLayout + "\n" + a.status.View()
 }
 
-func (a appModel) chat(width int, align lipgloss.Position) string {
-	editorView := a.editor.View(width, align)
-	lines := a.editor.Lines()
-	messagesView := a.messages.View()
-	if a.app.Session.ID == "" {
-		messagesView = a.home()
-	}
-	editorHeight := max(lines, 5)
-
-	t := theme.CurrentTheme()
-	centeredEditorView := lipgloss.PlaceHorizontal(
-		a.width,
-		align,
-		editorView,
-		styles.WhitespaceStyle(t.Background()),
-	)
-
-	mainLayout := layout.Render(
-		layout.FlexOptions{
-			Direction: layout.Column,
-			Width:     a.width,
-			Height:    a.height,
-		},
-		layout.FlexItem{
-			View: messagesView,
-			Grow: true,
-		},
-		layout.FlexItem{
-			View:      centeredEditorView,
-			FixedSize: 5,
+func (a appModel) openFile(filepath string) (tea.Model, tea.Cmd) {
+	var cmd tea.Cmd
+	response, err := a.app.Client.File.Read(
+		context.Background(),
+		opencode.FileReadParams{
+			Path: opencode.F(filepath),
 		},
 	)
-
-	if lines > 1 {
-		editorWidth := min(a.width, 80)
-		editorX := (a.width - editorWidth) / 2
-		editorY := a.height - editorHeight
-		mainLayout = layout.PlaceOverlay(
-			editorX,
-			editorY,
-			a.editor.Content(width, align),
-			mainLayout,
-		)
+	if err != nil {
+		slog.Error("Failed to read file", "error", err)
+		return a, toast.NewErrorToast("Failed to read file")
 	}
-
-	if a.showCompletionDialog {
-		editorWidth := min(a.width, 80)
-		editorX := (a.width - editorWidth) / 2
-		a.completions.SetWidth(editorWidth)
-		overlay := a.completions.View()
-		overlayHeight := lipgloss.Height(overlay)
-		editorY := a.height - editorHeight + 1
-
-		mainLayout = layout.PlaceOverlay(
-			editorX,
-			editorY-overlayHeight,
-			overlay,
-			mainLayout,
-		)
-	}
-
-	return mainLayout
+	a.fileViewer, cmd = a.fileViewer.SetFile(
+		filepath,
+		response.Content,
+		response.Type == "patch",
+	)
+	return a, cmd
 }
 
-func (a appModel) home() string {
+func (a appModel) home(width int) string {
 	t := theme.CurrentTheme()
 	baseStyle := styles.NewStyle().Background(t.Background())
 	base := baseStyle.Render
@@ -536,7 +608,7 @@ func (a appModel) home() string {
 
 	logoAndVersion := strings.Join([]string{logo, version}, "\n")
 	logoAndVersion = lipgloss.PlaceHorizontal(
-		a.width,
+		width,
 		lipgloss.Center,
 		logoAndVersion,
 		styles.WhitespaceStyle(t.Background()),
@@ -547,13 +619,15 @@ func (a appModel) home() string {
 		cmdcomp.WithLimit(6),
 	)
 	cmds := lipgloss.PlaceHorizontal(
-		a.width,
+		width,
 		lipgloss.Center,
 		commandsView.View(),
 		styles.WhitespaceStyle(t.Background()),
 	)
 
 	lines := []string{}
+	lines = append(lines, "")
+	lines = append(lines, "")
 	lines = append(lines, logoAndVersion)
 	lines = append(lines, "")
 	lines = append(lines, "")
@@ -561,18 +635,100 @@ func (a appModel) home() string {
 	// lines = append(lines, base("config ")+muted(config))
 	// lines = append(lines, "")
 	lines = append(lines, cmds)
+	lines = append(lines, "")
+	lines = append(lines, "")
+
+	mainHeight := lipgloss.Height(strings.Join(lines, "\n"))
 
-	return lipgloss.Place(
-		a.width,
-		a.height-5,
+	editorWidth := min(width, 80)
+	editorView := a.editor.View(editorWidth)
+	editorView = lipgloss.PlaceHorizontal(
+		width,
+		lipgloss.Center,
+		editorView,
+		styles.WhitespaceStyle(t.Background()),
+	)
+	lines = append(lines, editorView)
+
+	editorLines := a.editor.Lines()
+
+	mainLayout := lipgloss.Place(
+		width,
+		a.height,
 		lipgloss.Center,
 		lipgloss.Center,
 		baseStyle.Render(strings.Join(lines, "\n")),
 		styles.WhitespaceStyle(t.Background()),
 	)
+
+	editorX := (width - editorWidth) / 2
+	editorY := (a.height / 2) + (mainHeight / 2) - 2
+
+	if editorLines > 1 {
+		mainLayout = layout.PlaceOverlay(
+			editorX,
+			editorY,
+			a.editor.Content(editorWidth),
+			mainLayout,
+		)
+	}
+
+	if a.showCompletionDialog {
+		a.completions.SetWidth(editorWidth)
+		overlay := a.completions.View()
+		overlayHeight := lipgloss.Height(overlay)
+
+		mainLayout = layout.PlaceOverlay(
+			editorX,
+			editorY-overlayHeight+1,
+			overlay,
+			mainLayout,
+		)
+	}
+
+	return mainLayout
+}
+
+func (a appModel) chat(width int) string {
+	editorView := a.editor.View(width)
+	lines := a.editor.Lines()
+	messagesView := a.messages.View(width, a.height-5)
+
+	editorWidth := lipgloss.Width(editorView)
+	editorHeight := max(lines, 5)
+
+	mainLayout := messagesView + "\n" + editorView
+	editorX := (a.width - editorWidth) / 2
+
+	if lines > 1 {
+		editorY := a.height - editorHeight
+		mainLayout = layout.PlaceOverlay(
+			editorX,
+			editorY,
+			a.editor.Content(width),
+			mainLayout,
+		)
+	}
+
+	if a.showCompletionDialog {
+		a.completions.SetWidth(editorWidth)
+		overlay := a.completions.View()
+		overlayHeight := lipgloss.Height(overlay)
+		editorY := a.height - editorHeight + 1
+
+		mainLayout = layout.PlaceOverlay(
+			editorX,
+			editorY-overlayHeight,
+			overlay,
+			mainLayout,
+		)
+	}
+
+	return mainLayout
 }
 
 func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) {
+	var cmd tea.Cmd
 	cmds := []tea.Cmd{
 		util.CmdHandler(commands.CommandExecutedMsg(command)),
 	}
@@ -676,6 +832,22 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
 	case commands.ThemeListCommand:
 		themeDialog := dialog.NewThemeDialog()
 		a.modal = themeDialog
+	case commands.FileListCommand:
+		a.editor.Blur()
+		provider := completions.NewFileAndFolderContextGroup(a.app)
+		findDialog := dialog.NewFindDialog(provider)
+		findDialog.SetWidth(layout.Current.Container.Width - 8)
+		a.modal = findDialog
+	case commands.FileCloseCommand:
+		a.fileViewer, cmd = a.fileViewer.Clear()
+		cmds = append(cmds, cmd)
+	case commands.FileDiffToggleCommand:
+		a.fileViewer, cmd = a.fileViewer.ToggleDiff()
+		a.app.State.SplitDiff = a.fileViewer.DiffStyle() == fileviewer.DiffStyleSplit
+		a.app.SaveState()
+		cmds = append(cmds, cmd)
+	case commands.FileSearchCommand:
+		return a, nil
 	case commands.ProjectInitCommand:
 		cmds = append(cmds, a.app.InitializeProject(context.Background()))
 	case commands.InputClearCommand:
@@ -697,20 +869,6 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
 		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)
@@ -720,21 +878,62 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
 		a.messages = updated.(chat.MessagesComponent)
 		cmds = append(cmds, cmd)
 	case commands.MessagesPageUpCommand:
-		updated, cmd := a.messages.PageUp()
-		a.messages = updated.(chat.MessagesComponent)
-		cmds = append(cmds, cmd)
+		if a.fileViewer.HasFile() {
+			a.fileViewer, cmd = a.fileViewer.PageUp()
+			cmds = append(cmds, cmd)
+		} else {
+			updated, cmd := a.messages.PageUp()
+			a.messages = updated.(chat.MessagesComponent)
+			cmds = append(cmds, cmd)
+		}
 	case commands.MessagesPageDownCommand:
-		updated, cmd := a.messages.PageDown()
-		a.messages = updated.(chat.MessagesComponent)
-		cmds = append(cmds, cmd)
+		if a.fileViewer.HasFile() {
+			a.fileViewer, cmd = a.fileViewer.PageDown()
+			cmds = append(cmds, cmd)
+		} else {
+			updated, cmd := a.messages.PageDown()
+			a.messages = updated.(chat.MessagesComponent)
+			cmds = append(cmds, cmd)
+		}
 	case commands.MessagesHalfPageUpCommand:
-		updated, cmd := a.messages.HalfPageUp()
+		if a.fileViewer.HasFile() {
+			a.fileViewer, cmd = a.fileViewer.HalfPageUp()
+			cmds = append(cmds, cmd)
+		} else {
+			updated, cmd := a.messages.HalfPageUp()
+			a.messages = updated.(chat.MessagesComponent)
+			cmds = append(cmds, cmd)
+		}
+	case commands.MessagesHalfPageDownCommand:
+		if a.fileViewer.HasFile() {
+			a.fileViewer, cmd = a.fileViewer.HalfPageDown()
+			cmds = append(cmds, cmd)
+		} else {
+			updated, cmd := a.messages.HalfPageDown()
+			a.messages = updated.(chat.MessagesComponent)
+			cmds = append(cmds, cmd)
+		}
+	case commands.MessagesPreviousCommand:
+		updated, cmd := a.messages.Previous()
 		a.messages = updated.(chat.MessagesComponent)
 		cmds = append(cmds, cmd)
-	case commands.MessagesHalfPageDownCommand:
-		updated, cmd := a.messages.HalfPageDown()
+	case commands.MessagesNextCommand:
+		updated, cmd := a.messages.Next()
 		a.messages = updated.(chat.MessagesComponent)
 		cmds = append(cmds, cmd)
+	case commands.MessagesLayoutToggleCommand:
+		a.messagesRight = !a.messagesRight
+		a.app.State.MessagesRight = a.messagesRight
+		a.app.SaveState()
+	case commands.MessagesCopyCommand:
+		selected := a.messages.Selected()
+		if selected != "" {
+			cmd = tea.SetClipboard(selected)
+			cmds = append(cmds, cmd)
+			cmd = toast.NewSuccessToast("Message copied to clipboard")
+			cmds = append(cmds, cmd)
+		}
+	case commands.MessagesRevertCommand:
 	case commands.AppExitCommand:
 		return a, tea.Quit
 	}
@@ -776,6 +975,8 @@ func NewModel(app *app.App) tea.Model {
 		showCompletionDialog: false,
 		toastManager:         toast.NewToastManager(),
 		interruptKeyState:    InterruptKeyIdle,
+		fileViewer:           fileviewer.New(app),
+		messagesRight:        app.State.MessagesRight,
 	}
 
 	return model

+ 109 - 0
packages/tui/internal/util/file.go

@@ -0,0 +1,109 @@
+package util
+
+import (
+	"fmt"
+	"path/filepath"
+	"strings"
+	"unicode"
+
+	"github.com/charmbracelet/lipgloss/v2/compat"
+	"github.com/charmbracelet/x/ansi"
+	"github.com/sst/opencode/internal/styles"
+	"github.com/sst/opencode/internal/theme"
+)
+
+var RootPath string
+var CwdPath string
+
+type fileRenderer struct {
+	filename string
+	content  string
+	height   int
+}
+
+type fileRenderingOption func(*fileRenderer)
+
+func WithTruncate(height int) fileRenderingOption {
+	return func(c *fileRenderer) {
+		c.height = height
+	}
+}
+
+func RenderFile(
+	filename string,
+	content string,
+	width int,
+	options ...fileRenderingOption) string {
+	t := theme.CurrentTheme()
+	renderer := &fileRenderer{
+		filename: filename,
+		content:  content,
+	}
+	for _, option := range options {
+		option(renderer)
+	}
+
+	lines := []string{}
+	for line := range strings.SplitSeq(content, "\n") {
+		line = strings.TrimRightFunc(line, unicode.IsSpace)
+		line = strings.ReplaceAll(line, "\t", "  ")
+		lines = append(lines, line)
+	}
+	content = strings.Join(lines, "\n")
+
+	if renderer.height > 0 {
+		content = TruncateHeight(content, renderer.height)
+	}
+	content = fmt.Sprintf("```%s\n%s\n```", Extension(renderer.filename), content)
+	content = ToMarkdown(content, width, t.BackgroundPanel())
+	return content
+}
+
+func TruncateHeight(content string, height int) string {
+	lines := strings.Split(content, "\n")
+	if len(lines) > height {
+		return strings.Join(lines[:height], "\n")
+	}
+	return content
+}
+
+func Relative(path string) string {
+	path = strings.TrimPrefix(path, CwdPath+"/")
+	return strings.TrimPrefix(path, RootPath+"/")
+}
+
+func Extension(path string) string {
+	ext := filepath.Ext(path)
+	if ext == "" {
+		ext = ""
+	} else {
+		ext = strings.ToLower(ext[1:])
+	}
+	return ext
+}
+
+func ToMarkdown(content string, width int, backgroundColor compat.AdaptiveColor) string {
+	r := styles.GetMarkdownRenderer(width-7, backgroundColor)
+	content = strings.ReplaceAll(content, RootPath+"/", "")
+	rendered, _ := r.Render(content)
+	lines := strings.Split(rendered, "\n")
+
+	if len(lines) > 0 {
+		firstLine := lines[0]
+		cleaned := ansi.Strip(firstLine)
+		nospace := strings.ReplaceAll(cleaned, " ", "")
+		if nospace == "" {
+			lines = lines[1:]
+		}
+		if len(lines) > 0 {
+			lastLine := lines[len(lines)-1]
+			cleaned = ansi.Strip(lastLine)
+			nospace = strings.ReplaceAll(cleaned, " ", "")
+			if nospace == "" {
+				lines = lines[:len(lines)-1]
+			}
+		}
+	}
+	content = strings.Join(lines, "\n")
+	return strings.TrimSuffix(content, "\n")
+}

+ 8 - 1
stainless.yml

@@ -51,9 +51,16 @@ resources:
       get: get /app
       init: post /app/init
 
+  find:
+    methods:
+      text: get /find
+      files: get /find/file
+      symbols: get /find/symbol
+
   file:
     methods:
-      search: get /file
+      read: get /file
+      status: get /file/status
 
   config:
     models: